fix(sync): route sessions by server-confirmed directory, unstick queued sends
Session directory resolution had no precedence contract: the selection-time directory short-circuited every lookup, and a persisted runtime value was consulted before the authoritative record. A worktree session selected before its directory store bootstrapped kept the active-directory fallback, and that guess was persisted, so it survived reloads and restarts. Directory resolution now lives in one module and orders sources by whether the server confirmed the path, not by whether the value is local or synced: authoritative (the child store that holds the session) > server-confirmed selection > worktree attachment/metadata (the requested path, pre-canonical) > remembered. A guessed selection is no longer persisted, remembered, or ranked. Chips read the same resolution the composer used, so queue keys cannot diverge. Queued auto-send could strand an item indefinitely: backoff, missing send configuration, and the recent-abort window all returned without scheduling a wake-up, so the queue only retried when an unrelated status or directory change re-ran the effect. A retry scheduler now wakes it at the earliest known time. A rejected send rolls the optimistic message back while the composer stays silent for transport failures, which makes it indistinguishable from nothing happening. Failures are now recorded to a bounded in-memory log surfaced in the About diagnostics report, alongside a directory-resolution breakdown, plus __opencodeDebug.diagnoseSessionDirectory() and getRecentSendFailures(). Prompted by a report of worktree prompting silently failing. That failure was not reproduced locally, so the diagnostics are what will identify it.
This commit is contained in:
@@ -33,12 +33,47 @@ export const isQueuedAutoSendBackedOff = (
|
||||
now: number,
|
||||
): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt;
|
||||
|
||||
const hasRecentAbort = (sessionId: string): boolean => {
|
||||
export const createQueuedAutoSendRetryScheduler = (
|
||||
onWake: () => void,
|
||||
now: () => number = Date.now,
|
||||
scheduleTimeout: (callback: () => void, delay: number) => ReturnType<typeof setTimeout> = setTimeout,
|
||||
cancelTimeout: (timer: ReturnType<typeof setTimeout>) => void = clearTimeout,
|
||||
) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scheduledAt: number | null = null;
|
||||
|
||||
return {
|
||||
schedule(retryAt: number) {
|
||||
if (scheduledAt !== null && scheduledAt <= retryAt) return;
|
||||
if (timer !== null) cancelTimeout(timer);
|
||||
scheduledAt = retryAt;
|
||||
timer = scheduleTimeout(() => {
|
||||
timer = null;
|
||||
scheduledAt = null;
|
||||
onWake();
|
||||
}, Math.max(0, retryAt - now()));
|
||||
},
|
||||
dispose() {
|
||||
if (timer !== null) cancelTimeout(timer);
|
||||
timer = null;
|
||||
scheduledAt = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* When the abort window is still open, returns the time it expires so the
|
||||
* caller can wake the queue then. Returns `null` once sending is allowed
|
||||
* again — a queued item must not wait for an unrelated state change to be
|
||||
* retried after the window closes.
|
||||
*/
|
||||
const getAbortHoldUntil = (sessionId: string): number | null => {
|
||||
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
|
||||
if (!abortRecord) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS;
|
||||
const holdUntil = abortRecord.timestamp + RECENT_ABORT_WINDOW_MS;
|
||||
return Date.now() < holdUntil ? holdUntil : null;
|
||||
};
|
||||
|
||||
export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => {
|
||||
@@ -149,6 +184,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
|
||||
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
|
||||
const autoReviewBlockedSessionsRef = React.useRef<Set<string>>(new Set());
|
||||
const [retryTick, setRetryTick] = React.useState(0);
|
||||
const retryScheduler = React.useMemo(
|
||||
() => createQueuedAutoSendRetryScheduler(() => setRetryTick((value) => value + 1)),
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => () => retryScheduler.dispose(), [retryScheduler]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
@@ -164,7 +206,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
if (inFlightSessionsRef.current.has(targetKey)) {
|
||||
return;
|
||||
}
|
||||
if (hasRecentAbort(sessionId)) {
|
||||
const abortHoldUntil = getAbortHoldUntil(sessionId);
|
||||
if (abortHoldUntil !== null) {
|
||||
retryScheduler.schedule(abortHoldUntil);
|
||||
return;
|
||||
}
|
||||
if (useAutoReviewStore.getState().isRunningForSession(sessionId)) {
|
||||
@@ -185,7 +229,8 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
const failure = sendFailuresRef.current.get(targetKey);
|
||||
if (failure && failure.messageId !== payload.queuedMessageId) {
|
||||
sendFailuresRef.current.delete(targetKey);
|
||||
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
|
||||
} else if (failure && isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
|
||||
retryScheduler.schedule(failure.nextAttemptAt);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,6 +240,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
? captured
|
||||
: resolveSessionSendConfig(sessionId);
|
||||
if (!resolved.providerID || !resolved.modelID) {
|
||||
// Legacy queues may predate captured send configuration. Config
|
||||
// hydration is asynchronous, so retry instead of stranding the item
|
||||
// until an unrelated status or directory update happens.
|
||||
retryScheduler.schedule(Date.now() + AUTO_SEND_RETRY_BASE_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,11 +262,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
console.warn('[queue] queued auto-send failed:', error);
|
||||
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
|
||||
const failures = priorFailures + 1;
|
||||
const nextAttemptAt = Date.now() + getQueuedAutoSendRetryDelayMs(failures);
|
||||
sendFailuresRef.current.set(targetKey, {
|
||||
messageId: payload.queuedMessageId,
|
||||
failures,
|
||||
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
|
||||
nextAttemptAt,
|
||||
});
|
||||
retryScheduler.schedule(nextAttemptAt);
|
||||
} finally {
|
||||
inFlightSessionsRef.current.delete(targetKey);
|
||||
}
|
||||
@@ -257,5 +308,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
});
|
||||
|
||||
previousStatusRef.current = nextStatusMap;
|
||||
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]);
|
||||
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user