From a44d291cb5c75d5ec006d0453d05ee1cec5319c7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 4 Aug 2026 00:42:34 +0300 Subject: [PATCH] fix(sync): settle a guessed session directory once its owner is known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a session whose directory this client has not indexed yet routes it through the active directory. That is a deliberate, documented guess: it keeps routing usable while the owning store bootstraps, and it is excluded from both the resolver and persistence. Nothing settled the guess afterwards. `setSessionDirectory` performs exactly that promotion, but only confirmed destinations call it — a completed move or a worktree this client created. A session whose directory the client learned about later, such as one in a worktree created outside this client, kept the guess forever: every message fetch was addressed to the parent repository, which does not own the session. Captured for such a session before this change, with the session already indexed and its owning store known: routedDirectory .../worktree/feature currentSessionDirectory /repo <- guess, never settled opencodeClientDirectory /repo conflict selected -> /repo and after: routedDirectory .../worktree/feature currentSessionDirectory .../worktree/feature opencodeClientDirectory .../worktree/feature conflict null Directory bootstrap completion is the moment the authoritative directory first becomes readable, so the promotion runs there. It only ever promotes a guess: a confirmed selection and a selection that has since moved on are both left alone, and tests cover both directions. This removes a real routing split-brain. It does not by itself fix the reported symptom of a session created mid-session never rendering; that remains open. --- .../sync/session-directory-adoption.test.ts | 82 +++++++++++++++++++ packages/ui/src/sync/session-ui-store.ts | 26 ++++++ packages/ui/src/sync/sync-context.tsx | 11 +++ 3 files changed, 119 insertions(+) create mode 100644 packages/ui/src/sync/session-directory-adoption.test.ts diff --git a/packages/ui/src/sync/session-directory-adoption.test.ts b/packages/ui/src/sync/session-directory-adoption.test.ts new file mode 100644 index 00000000..81821dc0 --- /dev/null +++ b/packages/ui/src/sync/session-directory-adoption.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, test } from "bun:test" + +import { ChildStoreManager } from "./child-store" +import { setSyncRefs } from "./sync-refs" +import { useSessionUIStore } from "./session-ui-store" + +/** + * Selecting a session whose directory this client has not indexed yet routes it + * through the active directory as a deliberate guess. Nothing used to settle + * that guess once the owning directory finished bootstrapping, so every fetch + * stayed addressed to a directory that does not own the session and the session + * never rendered. + * + * These tests pin both directions: a guess is promoted once the authoritative + * directory becomes readable, and a confirmed selection is never rewritten. + */ + +const PARENT = "/repo" +const WORKTREE = "/repo/.worktrees/feature" +const SESSION_ID = "ses_directory_adoption" + +const indexSessionIn = (manager: ChildStoreManager, directory: string): void => { + const store = manager.ensureChild(directory, { bootstrap: false }) + store.setState({ + session: [{ id: SESSION_ID, directory, title: "test" } as never], + }) +} + +let manager: ChildStoreManager + +beforeEach(() => { + manager = new ChildStoreManager() + setSyncRefs({} as never, manager, PARENT) + useSessionUIStore.getState().setCurrentSession(null) +}) + +describe("adoptAuthoritativeSessionDirectory", () => { + test("promotes a guessed selection once the owning directory is indexed", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID) + expect(useSessionUIStore.getState().currentSessionDirectory).not.toBe(WORKTREE) + + indexSessionIn(manager, WORKTREE) + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE) + }) + + test("does nothing while the owning directory is still unknown", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID) + const before = useSessionUIStore.getState().currentSessionDirectory + + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(before) + }) + + test("never rewrites a selection that was confirmed at selection time", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID, WORKTREE) + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE) + + // A different directory claiming the session must not move a confirmed + // selection: the confirmed value outranks anything sync learns later. + indexSessionIn(manager, PARENT) + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE) + }) + + test("is a no-op for a session that is no longer selected", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID) + indexSessionIn(manager, WORKTREE) + useSessionUIStore.getState().setCurrentSession("ses_other") + + // Whatever the new selection resolved to, a late adoption for the previous + // session must not touch it. + const before = useSessionUIStore.getState().currentSessionDirectory + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory(SESSION_ID) + + expect(useSessionUIStore.getState().currentSessionId).toBe("ses_other") + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(before) + }) +}) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 4f65e98b..87721194 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -352,6 +352,12 @@ export type SessionUIState = { debugSessionMessages: (sessionId: string) => Promise pollForTokenUpdates: () => void setSessionDirectory: (sessionId: string, directory: string | null) => void + /** + * Replace a guessed selection directory with the authoritative one once sync + * has indexed the session. Safe to call at any time: it only ever promotes a + * guess, never overrides a confirmed selection. + */ + adoptAuthoritativeSessionDirectory: (sessionId?: string) => void } // --------------------------------------------------------------------------- @@ -1739,6 +1745,26 @@ export const useSessionUIStore = create()((set, get) => ({ // Handled by sync system's SSE stream }, + adoptAuthoritativeSessionDirectory: (sessionId) => { + const target = sessionId ?? get().currentSessionId + // Only a guess is promoted. A confirmed selection outranks anything sync + // learns later, and a selection that has since moved on must not be + // rewritten by a directory that finished bootstrapping in the background. + if (!target || target !== guessedSelectionSessionId) return + if (target !== get().currentSessionId) return + + const authoritative = getAuthoritativeSessionDirectory(target) + if (!authoritative) return + + // The selection stops being a guess even when the directory is unchanged: + // the value has now been confirmed by the store that owns the session. + guessedSelectionSessionId = null + if (authoritative !== get().currentSessionDirectory) { + set({ currentSessionDirectory: authoritative }) + } + writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId: target, directory: authoritative }) + }, + setSessionDirectory: (sessionId, directory) => { const normalized = normalizePath(directory) // Callers set this from a confirmed destination (a completed move, a diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 9e1da2b4..b4ff6c19 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -34,6 +34,7 @@ import { countSyncPerformance } from "./performance-diagnostics" import { runBackgroundNetworkTask } from "@/lib/background-network" import { setActionRefs } from "./session-actions" import { setSyncRefs, getAllSyncSessions } from "./sync-refs" +import { useSessionUIStore } from "./session-ui-store" import { stripSessionDiffSnapshots } from "./sanitize" import { applySessionEventToGlobalSessions } from "./session-event-router" import { syncDebug } from "./debug" @@ -1949,6 +1950,16 @@ export function SyncProvider(props: { const result = await runBootstrap(0) if (result === "failed") throw new Error(`Directory bootstrap failed for ${directory}`) + + // Selecting a session whose directory this client had not indexed yet + // routes it through the active directory as a documented guess. This is + // the moment that guess can be settled: the owning store now holds the + // session, so the authoritative directory is finally readable. Without + // this the guess survives, every fetch is addressed to a directory that + // does not own the session, and the session never renders. + if (result === "complete") { + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + } }, onDispose: (directory) => { messageLoader.invalidateDirectory(directory)