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:
Bohdan Triapitsyn
2026-08-03 12:51:12 +03:00
parent c5bf04b53a
commit 2c52240f8e
15 changed files with 814 additions and 41 deletions
@@ -111,7 +111,15 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
// Must use the same resolution the composer used to build the queue key —
// reading currentSessionDirectory raw can key the chips to a different
// directory than the one the messages were queued under.
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
const queueKey = target ? getMessageQueueKey(target) : null;
const queuedMessages = useMessageQueueStore(
@@ -29,12 +29,61 @@ mock.module('@/sync/session-ui-store', () => ({
import {
buildQueuedAutoSendPayload,
createQueuedAutoSendRetryScheduler,
getQueuedAutoSendRetryDelayMs,
isQueuedAutoSendBackedOff,
sendQueuedAutoSendPayload,
shouldDispatchQueuedAutoSend,
} from './useQueuedMessageAutoSend';
describe('queued auto-send retry scheduler', () => {
test('wakes the queue when backoff expires', () => {
const callbacks = new Map<number, () => void>();
let nextTimer = 0;
let wakeups = 0;
const scheduler = createQueuedAutoSendRetryScheduler(
() => { wakeups += 1; },
() => 1_000,
(callback, delay) => {
callbacks.set(++nextTimer, callback);
expect(delay).toBe(500);
return nextTimer as unknown as ReturnType<typeof setTimeout>;
},
(timer) => { callbacks.delete(timer as unknown as number); },
);
scheduler.schedule(1_500);
expect(callbacks.size).toBe(1);
callbacks.values().next().value?.();
expect(wakeups).toBe(1);
});
test('keeps the earliest retry and cancels it on dispose', () => {
const callbacks = new Map<number, () => void>();
let nextTimer = 0;
const delays: number[] = [];
const scheduler = createQueuedAutoSendRetryScheduler(
() => undefined,
() => 1_000,
(callback, delay) => {
callbacks.set(++nextTimer, callback);
delays.push(delay);
return nextTimer as unknown as ReturnType<typeof setTimeout>;
},
(timer) => { callbacks.delete(timer as unknown as number); },
);
scheduler.schedule(3_000);
scheduler.schedule(4_000);
scheduler.schedule(2_000);
expect(delays).toEqual([2_000, 1_000]);
expect(callbacks.size).toBe(1);
scheduler.dispose();
expect(callbacks.size).toBe(0);
});
});
describe('shouldDispatchQueuedAutoSend', () => {
test('dispatches only after an active session becomes idle', () => {
expect(shouldDispatchQueuedAutoSend('busy', 'idle', false)).toBe(true);
@@ -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]);
}
+106 -2
View File
@@ -1,12 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionUIStore, getRememberedSessionDirectory } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { checkIsGitRepository } from '@/lib/gitApi';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard';
import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs';
import { getSyncSessions, getSyncMessages, getSyncParts, getAllSyncSessions, getSyncSessionDirectory } from '@/sync/sync-refs';
import {
describeSessionDirectorySources,
resolveSessionDirectoryFromSources,
} from '@/sync/session-directory-resolution';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getRecentSendFailures } from '@/sync/send-failure-log';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useStreamingStore } from '@/sync/streaming';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -375,12 +382,107 @@ export const debugUtils = {
openchamber: {
settingsInfo,
},
// Empty is a meaningful answer here: it means no prompt was rejected in
// this session, so a "my message disappeared" report is not a rejected
// send and needs a different explanation.
recentSendFailures: getRecentSendFailures(),
currentSessionDirectoryResolution: sessionState.currentSessionId
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
: null,
};
console.log('[DEBUG] App status snapshot:', report);
return report;
},
/**
* Prompt sends that were rejected and rolled back in this app session.
* Newest first; empty means no send was rejected.
*/
getRecentSendFailures() {
const failures = getRecentSendFailures();
if (failures.length === 0) {
console.log('[OK] No prompt sends were rejected in this session.');
} else {
console.warn(`[ALERT] ${failures.length} rejected prompt send(s):`);
console.table(failures);
}
return failures;
},
/**
* Report how a session's directory is resolved, from every source, in
* precedence order. A send is routed by the winning value, so a disagreement
* here explains a prompt that vanishes without an error: it was posted
* against a directory that does not own the session.
*/
diagnoseSessionDirectory(sessionId?: string) {
const sessionState = useSessionUIStore.getState();
const targetSessionId = sessionId ?? sessionState.currentSessionId;
if (!targetSessionId) {
console.log('[ERROR] No session selected and no session id passed');
return null;
}
const attachment = getAttachedSessionDirectory(
useSessionWorktreeStore.getState().getAttachment(targetSessionId),
);
const worktreeMetadata = sessionState.worktreeMetadata.get(targetSessionId)?.path ?? null;
const owningStoreDirectory = getSyncSessionDirectory(targetSessionId);
const sessionRecord = getAllSyncSessions().find((session) => session.id === targetSessionId);
const recordDirectory = (sessionRecord as { directory?: string | null } | undefined)?.directory ?? null;
const selected = targetSessionId === sessionState.currentSessionId
? sessionState.currentSessionDirectory
: null;
const remembered = getRememberedSessionDirectory(targetSessionId);
const sources = {
attachment,
worktreeMetadata,
authoritative: owningStoreDirectory ?? recordDirectory,
selected,
remembered: remembered.runtime,
};
const resolution = resolveSessionDirectoryFromSources(sources);
const routedDirectory = sessionState.getDirectoryForSession(targetSessionId);
const report = {
sessionId: targetSessionId,
isCurrentSession: targetSessionId === sessionState.currentSessionId,
routedDirectory,
resolvedFrom: resolution.source,
conflict: resolution.conflict,
sources: describeSessionDirectorySources(sources),
details: {
owningChildStore: owningStoreDirectory,
sessionRecordDirectory: recordDirectory,
sessionIndexed: Boolean(sessionRecord),
currentSessionDirectory: sessionState.currentSessionDirectory,
rememberedForRuntime: remembered.runtime,
persistedAcrossRestarts: remembered.persisted,
activeDirectory: useDirectoryStore.getState().currentDirectory ?? null,
opencodeClientDirectory: opencodeClient.getDirectory() ?? null,
},
};
console.log('[DEBUG] Session directory resolution:', report);
if (resolution.conflict) {
console.warn(
`[ALERT] Directory sources disagree: using "${resolution.directory}" (${resolution.source}) `
+ `while "${resolution.conflict.directory}" came from ${resolution.conflict.source}.`,
);
} else if (!routedDirectory) {
console.warn('[ALERT] No directory resolved for this session — sends fall back to the active directory.');
} else {
console.log('[OK] All known sources agree on the session directory.');
}
return report;
},
async buildDiagnosticsReport() {
const report = await this.getAppStatus();
return JSON.stringify(report, null, 2);
@@ -697,6 +799,8 @@ if (typeof window !== 'undefined') {
console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)');
console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array');
console.log(' __opencodeDebug.getAppStatus() - Show app status snapshot');
console.log(' __opencodeDebug.diagnoseSessionDirectory(sessionId?) - Show how the session directory is resolved');
console.log(' __opencodeDebug.getRecentSendFailures() - List prompt sends that were rejected and rolled back');
console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic');
console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages');
console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses');
@@ -127,6 +127,7 @@ mock.module('./useGlobalSessionsStore', () => ({
}));
mock.module('@/sync/sync-refs', () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registeredDirectories.push({ sessionID, directory });
},
+25
View File
@@ -197,6 +197,30 @@ Directory stores also own session-keyed sidecar notification channels for permis
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
## Session directory resolution
`session-directory-resolution.ts` owns the precedence used to answer "which directory does this session belong to". Every send, message fetch, message-queue key, and send-confirmation lookup is routed by that answer, so a wrong value is not a display problem: the prompt is posted against a directory that does not own the session, the request is rejected, and the optimistic message is rolled back with no visible error.
Precedence, highest authority first:
The discriminator is whether the server confirmed the path, not whether the value is local or synced.
| Source | Meaning |
|---|---|
| `authoritative` | The child store that actually holds the session, then its own record |
| `selected` | Server-confirmed directory captured at selection; a guessed one is never passed |
| `attachment` | Worktree attachment recorded by this client; the *requested* path |
| `worktree-metadata` | Worktree captured when the session was created in one; the *requested* path |
| `remembered` | Per-runtime directory persisted across restarts |
Rules:
1. `getSyncSessionDirectory()` is the authoritative session→directory mapping: a session lives in exactly the child store for its directory, whether or not the server populated `session.directory`. `null` means "not indexed yet", never "no directory".
2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent.
3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts.
4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically.
5. A disagreement between sources is logged once per session, and `__opencodeDebug.diagnoseSessionDirectory()` reports every source in precedence order.
## Session action rules
Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists.
@@ -207,6 +231,7 @@ Rules:
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
Examples of global-store updates performed in `session-actions.ts`:
@@ -42,6 +42,7 @@ mock.module("../session-ui-store", () => ({
}))
mock.module("../sync-refs", () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registerSessionDirectoryCalls.push({ sessionID, directory })
},
@@ -4,11 +4,16 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto
const storage = new Map<string, string>()
const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = []
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
let createdSessionDirectory: string | undefined
const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? [])
mock.module("zustand", () => ({
create: () => (initializer: (set: (patch: unknown | ((state: unknown) => unknown)) => void, get: () => unknown) => Record<string, unknown>) => {
create: () => (initializer: (
set: (patch: unknown | ((state: unknown) => unknown)) => void,
get: () => unknown,
api?: unknown,
) => Record<string, unknown>) => {
let state: Record<string, unknown>
const get = () => state
const set = (patch: unknown | ((current: Record<string, unknown>) => unknown)) => {
@@ -16,7 +21,12 @@ mock.module("zustand", () => ({
state = next && typeof next === "object" ? { ...state, ...(next as Record<string, unknown>) } : state
}
state = initializer(set, get)
state = initializer(set, get, {
setState: set,
getState: get,
getInitialState: get,
subscribe: () => () => undefined,
} as never)
const store = ((selector?: (current: Record<string, unknown>) => unknown) => (
typeof selector === "function" ? selector(state) : state
@@ -53,6 +63,11 @@ const deferredStorage: Storage = {
mock.module("@/stores/utils/safeStorage", () => ({
getDeferredSafeStorage: () => deferredStorage,
createDeferredSafeJSONStorage: () => ({
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined,
}),
}))
mock.module("@/lib/opencode/client", () => ({
@@ -224,15 +239,18 @@ mock.module("../sync-refs", () => ({
getSyncMessages: () => [],
getSyncParts: () => [],
getAllSyncSessions: () => [],
getSyncSessionDirectory: () => null,
}))
mock.module("../session-actions", () => ({
createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => {
createSessionCalls.push({ title, directory, parentID, metadata })
return { id: "ses_issue_2039", directory }
return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
}),
deleteSession: mock(async () => true),
deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })),
archiveSession: mock(async () => true),
archiveSessions: mock(async () => ({ archivedIds: [], failedIds: [] })),
updateSessionTitle: mock(async () => undefined),
shareSession: mock(async () => undefined),
unshareSession: mock(async () => undefined),
@@ -242,6 +260,9 @@ mock.module("../session-actions", () => ({
unrevertSession: mock(async () => undefined),
forkFromMessage: mock(async () => undefined),
fetchMessagesForSession: mock(async () => undefined),
getSessionLastAssistantModel: () => null,
patchSessionMetadata: mock(async () => undefined),
abortCurrentOperation: mock(async () => undefined),
}))
const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store")
@@ -298,6 +319,7 @@ describe("issue 2039 draft auto-accept", () => {
storage.clear()
createSessionCalls.length = 0
permissionAutoAcceptCalls.length = 0
createdSessionDirectory = undefined
useSessionUIStore.setState({
currentSessionId: null,
@@ -348,4 +370,45 @@ describe("issue 2039 draft auto-accept", () => {
expect(createSessionCalls).toHaveLength(0)
expect(permissionAutoAcceptCalls).toHaveLength(0)
})
test("uses the server-authoritative directory after worktree session creation", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: "/requested/worktree",
})
const result = await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
})
expect(createSessionCalls[0]?.directory).toBe("/requested/worktree")
expect(result?.directory).toBe("/canonical/worktree")
expect(useSessionUIStore.getState().currentSessionDirectory).toBe("/canonical/worktree")
})
test("routes the session by the canonical directory, not the requested worktree path", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: "/requested/worktree",
})
const created = await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
})
const sessionId = created?.sessionId ?? ""
// The worktree attachment still holds the path this client asked for. The
// directory every send, queue key, and confirmation lookup is routed by
// must be the canonical one the server returned.
useSessionUIStore.getState().setWorktreeMetadata(sessionId, {
path: "/requested/worktree",
projectDirectory: "/repo",
branch: "feature",
label: "feature",
})
expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree")
})
})
+49
View File
@@ -0,0 +1,49 @@
/**
* Recent prompt-send failures, kept in memory for diagnostics.
*
* A rejected send rolls the optimistic message back and, for transport-level
* failures, the composer stays silent by design. That makes a misrouted or
* refused prompt indistinguishable from "nothing happened" the user has
* nothing to report beyond "it disappeared".
*
* This buffer gives the failure somewhere to live until someone asks for it,
* via the About dialog's diagnostics report or `__opencodeDebug`. It is
* in-memory only: never persisted, never sent anywhere, and dropped on reload.
*/
const MAX_RECORDED_SEND_FAILURES = 20
const MAX_REASON_LENGTH = 200
export type SendFailureRecord = {
at: number
sessionId: string
messageId: string
/** Directory the prompt was routed to — the value under suspicion. */
directory: string | null
/** HTTP status, or null for a transport failure with no response. */
status: number | null
/** Whether the send may still have been accepted server-side. */
ambiguous: boolean
/** Whether a confirmation refetch ran and failed to find the message. */
confirmationChecked: boolean
reason: string
}
const records: SendFailureRecord[] = []
export function recordSendFailure(record: Omit<SendFailureRecord, 'at' | 'reason'> & { reason: string }): void {
records.push({
...record,
reason: record.reason.slice(0, MAX_REASON_LENGTH),
at: Date.now(),
})
if (records.length > MAX_RECORDED_SEND_FAILURES) {
records.splice(0, records.length - MAX_RECORDED_SEND_FAILURES)
}
}
/** Newest first. */
export function getRecentSendFailures(): SendFailureRecord[] {
return [...records].reverse()
}
@@ -245,6 +245,7 @@ mock.module("./session-deletion-cleanup", () => ({
}))
mock.module("./sync-refs", () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registeredSessionDirectories.push({ sessionID, directory })
},
+22 -1
View File
@@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client"
import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs"
import { recordSendFailure } from "./send-failure-log"
import { isSyntheticPart } from "@/lib/messages/synthetic"
import { materializeSessionSnapshots } from "./materialization"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
@@ -1176,7 +1177,9 @@ export async function optimisticSend(input: {
try {
await input.send(messageID)
} catch (error) {
const acceptedRecords = isAmbiguousSendFailure(error)
const status = getErrorStatus(error)
const ambiguousFailure = isAmbiguousSendFailure(error)
const acceptedRecords = ambiguousFailure
? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory)
: null
@@ -1190,6 +1193,24 @@ export async function optimisticSend(input: {
return
}
// The rollback below makes the user's message disappear with no other
// trace, and the composer intentionally stays silent for transport-level
// failures. Record the failure so the About dialog's diagnostics report can
// answer "it disappeared and nothing happened" with an actual cause.
// `reason` is truncated by the recorder: a rejected send echoes the
// provider/OpenCode response body, which this log has no reason to keep.
const failureRecord = {
sessionId: input.sessionId,
messageId: messageID,
directory: targetDirectory ?? null,
status,
ambiguous: ambiguousFailure,
confirmationChecked: ambiguousFailure,
reason: error instanceof Error ? error.message : String(error),
}
recordSendFailure(failureRecord)
console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord)
// Rollback via optimistic infrastructure
_optimisticRemove({
sessionID: input.sessionId,
@@ -0,0 +1,131 @@
import { describe, expect, test } from 'bun:test';
import {
describeSessionDirectorySources,
resolveSessionDirectoryFromSources,
} from './session-directory-resolution';
const WORKTREE = '/repo/.worktrees/feature';
const MAIN = '/repo';
describe('resolveSessionDirectoryFromSources', () => {
test('authoritative directory beats a selection-time fallback', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('authoritative');
expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN });
});
test('authoritative directory beats a directory persisted across restarts', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
remembered: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN });
});
test('the indexed directory outranks a locally requested worktree path', () => {
// attachment/worktreeMetadata hold the path this client asked for, before
// the server canonicalized it. Letting them win would route prompts to a
// directory that no child store owns.
const resolution = resolveSessionDirectoryFromSources({
attachment: '/requested/worktree',
worktreeMetadata: '/requested/worktree',
authoritative: WORKTREE,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('authoritative');
expect(resolution.conflict).toEqual({ source: 'attachment', directory: '/requested/worktree' });
});
test('a worktree attachment is used while the session is not indexed yet', () => {
// A guessed selection is not passed as `selected` at all, so the worktree
// assignment is the best available value during the bootstrap race.
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: null,
attachment: WORKTREE,
remembered: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('attachment');
expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN });
});
test('a server-confirmed selection outranks the requested worktree path', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: '/canonical/worktree',
attachment: '/requested/worktree',
worktreeMetadata: '/requested/worktree',
});
expect(resolution.directory).toBe('/canonical/worktree');
expect(resolution.source).toBe('selected');
});
test('falls back to the selection hint while the session is not indexed yet', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: WORKTREE,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('selected');
expect(resolution.conflict).toBeNull();
});
test('agreeing sources report no conflict', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: WORKTREE,
remembered: WORKTREE,
});
expect(resolution.conflict).toBeNull();
});
test('reports the first disagreeing source, not the last', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: MAIN,
remembered: '/somewhere/else',
});
expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN });
});
test('treats missing and blank values as unknown, never as a directory', () => {
const resolution = resolveSessionDirectoryFromSources({
attachment: null,
worktreeMetadata: ' ',
authoritative: undefined,
selected: '',
});
expect(resolution.directory).toBeNull();
expect(resolution.source).toBe('none');
expect(resolution.conflict).toBeNull();
});
});
describe('describeSessionDirectorySources', () => {
test('lists populated sources in precedence order', () => {
expect(describeSessionDirectorySources({
remembered: MAIN,
authoritative: WORKTREE,
selected: '',
})).toEqual([
{ source: 'authoritative', directory: WORKTREE },
{ source: 'remembered', directory: MAIN },
]);
});
});
@@ -0,0 +1,137 @@
/**
* Session directory resolution precedence.
*
* A session's directory decides which OpenCode project every send, message
* fetch, queue key, and confirmation lookup is routed to. Getting it wrong is
* not a cosmetic problem: the prompt is posted against a directory that does
* not own the session, the send is rejected, and the optimistic message is
* rolled back with no visible error.
*
* The precedence below is deliberate and ordered by authority, not by
* convenience:
*
* The ordering discriminator is **whether the server confirmed the path**, not
* whether the value is local or synced:
*
* 1. `authoritative` the child store that actually holds the session, then
* the session's own record. Server-backed truth for an indexed session.
* 2. `selected` the directory captured when the session was selected, but
* only when it came from a server response (the directory `createSession`
* returned, which may be a canonicalized form of what was requested). A
* selection that fell back to the active directory is a guess and is not
* passed here at all.
* 3. `attachment` / `worktreeMetadata` the worktree this client assigned to
* the session. Both hold the *requested* path, before the server had a
* chance to canonicalize it, so they are a hint for a session sync has not
* indexed yet, never a correction of a confirmed one.
* 4. `remembered` the per-runtime directory persisted across restarts. Last
* resort: it survives reloads, so a value written from a startup fallback
* would otherwise outlive the race that produced it.
*
* Routing a prompt by an unconfirmed path posts it against a directory that
* does not own the session, and the send is rejected. Moves need no exception:
* a session move updates the owning child store before any client-side value.
*/
export type SessionDirectorySource =
| 'authoritative'
| 'selected'
| 'attachment'
| 'worktree-metadata'
| 'remembered'
| 'none'
export type SessionDirectorySources = {
/** Directory of the child store that holds the session, or its own record. */
authoritative?: string | null
/** Server-confirmed directory captured at selection. Never a guessed one. */
selected?: string | null
/** Worktree attachment recorded for this session; the requested path. */
attachment?: string | null
/** Worktree metadata captured when the session was created in a worktree. */
worktreeMetadata?: string | null
/** Directory persisted for this runtime; may outlive the race that wrote it. */
remembered?: string | null
}
export type SessionDirectoryResolution = {
directory: string | null
source: SessionDirectorySource
/**
* Set when a lower-priority source disagrees with the winning one. This is
* the signature of the stale-directory bug: a persisted or selection-time
* fallback pointing at the parent repository while the session lives in a
* worktree.
*/
conflict: { source: SessionDirectorySource; directory: string } | null
}
const RESOLUTION_ORDER: ReadonlyArray<Exclude<SessionDirectorySource, 'none'>> = [
'authoritative',
'selected',
'attachment',
'worktree-metadata',
'remembered',
]
const readSource = (
sources: SessionDirectorySources,
source: Exclude<SessionDirectorySource, 'none'>,
): string | null => {
const value = source === 'attachment'
? sources.attachment
: source === 'worktree-metadata'
? sources.worktreeMetadata
: source === 'authoritative'
? sources.authoritative
: source === 'selected'
? sources.selected
: sources.remembered
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
/**
* Resolve a session directory from every known source, reporting which source
* won and whether a weaker source disagreed.
*
* Callers normalize paths before passing them in; this module only orders
* authority and never rewrites a path.
*/
export const resolveSessionDirectoryFromSources = (
sources: SessionDirectorySources,
): SessionDirectoryResolution => {
let winner: { source: SessionDirectorySource; directory: string } | null = null
let conflict: { source: SessionDirectorySource; directory: string } | null = null
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (!directory) continue
if (!winner) {
winner = { source, directory }
continue
}
if (!conflict && directory !== winner.directory) {
conflict = { source, directory }
}
}
if (!winner) {
return { directory: null, source: 'none', conflict: null }
}
return { directory: winner.directory, source: winner.source, conflict }
}
/** Every source that carries a value, in precedence order. For diagnostics. */
export const describeSessionDirectorySources = (
sources: SessionDirectorySources,
): Array<{ source: SessionDirectorySource; directory: string }> => {
const described: Array<{ source: SessionDirectorySource; directory: string }> = []
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (directory) described.push({ source, directory })
}
return described
}
+139 -27
View File
@@ -40,7 +40,13 @@ import {
getSyncMessages,
getSyncParts,
getDirectoryState,
getSyncSessionDirectory,
} from "./sync-refs"
import {
resolveSessionDirectoryFromSources,
type SessionDirectoryResolution,
type SessionDirectorySources,
} from "./session-directory-resolution"
import { markSessionViewed } from "./notification-store"
import { setActiveSession } from "./sync-context"
import {
@@ -73,7 +79,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store"
import { getAttachedSessionDirectory } from "./session-worktree-contract"
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache"
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
@@ -394,23 +400,108 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW
return useSessionWorktreeStore.getState().getAttachment(sessionId)
}
/**
* Authoritative directory for a session: the child store that holds it, and
* only then the session record's own fields. `null` means "not indexed yet",
* never "no directory" callers must fall back rather than treat it as empty.
*/
const getAuthoritativeSessionDirectory = (sessionId: string): string | null => {
const owningDirectory = getSyncSessionDirectory(sessionId)
if (owningDirectory) return normalizePath(owningDirectory)
const target = getAllSyncSessions().find((s) => s.id === sessionId)
return target ? resolveDirectoryKey(target) : null
}
/**
* Directory remembered for a session in this runtime, plus the one persisted
* across restarts. Exported for diagnostics: a stale persisted directory is the
* hardest source to observe and the one that survives reloads, so a report that
* cannot show it cannot rule it out.
*/
export const getRememberedSessionDirectory = (sessionId: string): {
runtime: string | null
persisted: string | null
} => {
const key = runtimeMemoryKey()
const runtimeMemory = runtimeSessionMemory.get(key)
const persisted = readLastActiveSession(key)
return {
runtime: runtimeMemory?.sessionId === sessionId ? normalizePath(runtimeMemory.directory) : null,
persisted: persisted?.sessionId === sessionId ? normalizePath(persisted.directory) : null,
}
}
/**
* Session whose `currentSessionDirectory` is only the active directory, used
* because the session's own directory was not known at selection time. Such a
* value must never outrank a worktree assignment or reach persistence it is
* a guess, not a selection.
*/
let guessedSelectionSessionId: string | null = null
const collectSessionDirectorySources = (
sessionId: string,
getWtMeta: (id: string) => WorktreeMetadata | undefined,
selected: string | null,
): SessionDirectorySources => ({
authoritative: getAuthoritativeSessionDirectory(sessionId),
selected: sessionId === guessedSelectionSessionId ? null : normalizePath(selected),
attachment: getAttachedSessionDirectory(getAttachmentForSession(sessionId)),
worktreeMetadata: normalizePath(getWtMeta(sessionId)?.path ?? null),
remembered: getRememberedSessionDirectory(sessionId).runtime,
})
/**
* Conflicts already warned about, so a stale directory logs once instead of on
* every keystroke. Keyed by runtime *and* the exact pair of directories: the
* same session ID means a different thing in another runtime, and a conflict
* that reappears after being resolved is news worth logging again. Bounded so
* a long-lived session cannot grow it without limit.
*/
const reportedDirectoryConflicts = new Set<string>()
const MAX_REPORTED_DIRECTORY_CONFLICTS = 200
const reportSessionDirectoryConflict = (
sessionId: string,
resolution: SessionDirectoryResolution,
): void => {
if (!resolution.conflict) return
const conflictKey = JSON.stringify([
runtimeMemoryKey(),
sessionId,
resolution.directory,
resolution.conflict.source,
resolution.conflict.directory,
])
if (reportedDirectoryConflicts.has(conflictKey)) return
if (reportedDirectoryConflicts.size >= MAX_REPORTED_DIRECTORY_CONFLICTS) {
reportedDirectoryConflicts.clear()
}
reportedDirectoryConflicts.add(conflictKey)
console.warn(
"[session-directory] session directory sources disagree; using the higher-authority one. "
+ "Run __opencodeDebug.diagnoseSessionDirectory() for the full picture.",
{
sessionId,
using: resolution.source,
directory: resolution.directory,
conflictingSource: resolution.conflict.source,
conflictingDirectory: resolution.conflict.directory,
},
)
}
const resolveSessionDirectory = (
sessionId: string | null | undefined,
getWtMeta: (id: string) => WorktreeMetadata | undefined,
selected: string | null = null,
): string | null => {
if (!sessionId) return null
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const metaPath = getWtMeta(sessionId)?.path
if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath)
const runtimeMemory = runtimeSessionMemory.get(runtimeMemoryKey())
if (runtimeMemory?.sessionId === sessionId && runtimeMemory.directory) {
return normalizePath(runtimeMemory.directory)
}
const sessions = getAllSyncSessions()
const target = sessions.find((s) => s.id === sessionId)
if (!target) return null
return resolveDirectoryKey(target)
const resolution = resolveSessionDirectoryFromSources(
collectSessionDirectorySources(sessionId, getWtMeta, selected),
)
reportSessionDirectoryConflict(sessionId, resolution)
return resolution.directory
}
const activateConfigForDirectory = async (directory: string | null | undefined): Promise<void> => {
@@ -504,13 +595,18 @@ export async function materializeOpenDraftSession(selection: {
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
if (!created?.id) throw new Error("Failed to create session")
// The server response is authoritative. It may canonicalize a requested
// worktree path (for example through a symlink or platform path casing).
// Sending with the pre-canonical draft path can target a different
// directory scope than the session that was just created.
const createdDirectory = normalizePath(created.directory ?? draftDirectoryOverride ?? null)
persistDraftTarget({
projectId: draftProjectId,
directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null),
directory: createdDirectory,
})
const draftSyntheticParts = draft.syntheticParts
const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null)
const configState = useConfigStore.getState()
void activateConfigForDirectory(createdDirectory).catch((error) => {
console.warn("Failed to activate directory after creating session:", error)
@@ -604,7 +700,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
(sid) => get().worktreeMetadata.get(sid),
)
const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null
const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir
const knownDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir
const resolvedDir = knownDir ?? fallbackDir
// `fallbackDir` is the active directory, not this session's directory. It
// keeps routing usable while the owning directory store bootstraps, but it
// must never be remembered: a persisted guess outlives the race that
// produced it and survives reloads and restarts.
const isGuessedDir = knownDir === null
const projectsState = useProjectsStore.getState()
const sessionProject = resolvedDir
? resolveProjectForSessionDirectory(
@@ -617,12 +719,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races.
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
guessedSelectionSessionId = isGuessedDir && id ? id : null
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir })
// Keep the last NON-null session per runtime across app restarts (cold
// mobile launches reopen it after the instance reconnects). Going back to
// a draft intentionally does not erase it.
if (id) {
persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null })
persistLastActiveSession(key, { sessionId: id, directory: rememberedDir })
}
// Kick off the message fetch on the same tick, before React commits the
@@ -1560,16 +1664,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
},
getDirectoryForSession: (sessionId) => {
if (sessionId === get().currentSessionId && get().currentSessionDirectory) {
return get().currentSessionDirectory
}
const resolved = resolveSessionDirectory(sessionId, (sid) => get().worktreeMetadata.get(sid))
// The selection-time directory participates in resolution, it does not
// short-circuit it. For a worktree session selected before its directory
// store finished bootstrapping, that value is a startup fallback pointing
// at the parent repository; letting it win would route every send, queue
// key, and send-confirmation lookup to a directory that does not own the
// session.
const selected = sessionId === get().currentSessionId ? get().currentSessionDirectory : null
const resolved = resolveSessionDirectory(
sessionId,
(sid) => get().worktreeMetadata.get(sid),
selected,
)
if (resolved) return resolved
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const sessions = getAllSyncSessions()
const session = sessions.find((s) => s.id === sessionId)
if (session) return resolveDirectoryKey(session)
const globalStore = useGlobalSessionsStore.getState()
const globalSession = [...globalStore.activeSessions, ...globalStore.archivedSessions]
.find((s) => s.id === sessionId)
@@ -1634,6 +1741,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
setSessionDirectory: (sessionId, directory) => {
const normalized = normalizePath(directory)
// Callers set this from a confirmed destination (a completed move, a
// created worktree), so the selection is no longer a guess.
if (sessionId === guessedSelectionSessionId) {
guessedSelectionSessionId = null
}
if (sessionId === get().currentSessionId) {
set({ currentSessionDirectory: normalized })
writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId, directory: normalized })
+20
View File
@@ -17,6 +17,7 @@ const configListeners = new Set<(directory: string, config: Config) => void>()
let cachedSessionManager: ChildStoreManager | null = null
let cachedSessionSlices = new Map<string, State["session"]>()
let cachedSessionsById = new Map<string, State["session"][number]>()
let cachedSessionDirectoryById = new Map<string, string>()
export function setSyncRefs(
_sdk: OpencodeClient,
@@ -29,6 +30,7 @@ export function setSyncRefs(
cachedSessionManager = null
cachedSessionSlices = new Map()
cachedSessionsById = new Map()
cachedSessionDirectoryById = new Map()
}
_directory = directory
if (registerSessionDirectory) {
@@ -103,20 +105,38 @@ export function getAllSyncSessionMap(): ReadonlyMap<string, State["session"][num
const nextSlices = new Map<string, State["session"]>()
const nextSessionsById = new Map<string, State["session"][number]>()
const nextDirectoriesById = new Map<string, string>()
for (const [directory, store] of stores.children) {
const sessions = store.getState().session
nextSlices.set(directory, sessions)
for (const session of sessions) {
if (!session?.id) continue
nextSessionsById.set(session.id, session)
nextDirectoriesById.set(session.id, directory)
}
}
cachedSessionManager = stores
cachedSessionSlices = nextSlices
cachedSessionsById = nextSessionsById
cachedSessionDirectoryById = nextDirectoriesById
return cachedSessionsById
}
/**
* Directory of the child store that actually holds this session.
*
* This is the authoritative sessiondirectory mapping: a session is present in
* exactly the store for the directory it belongs to, regardless of whether the
* server populated `session.directory` on the record itself. Returns `null`
* when no initialized child store contains the session, which means "unknown",
* never "no directory".
*/
export function getSyncSessionDirectory(sessionId: string): string | null {
if (!sessionId) return null
getAllSyncSessionMap()
return cachedSessionDirectoryById.get(sessionId) ?? null
}
/** Read messages for a session from current directory's child store */
export function getSyncMessages(sessionId: string, directory?: string) {
return getDirectoryState(directory)?.message[sessionId] ?? []