fix(sync): settle a guessed session directory once its owner is known

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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-04 00:42:34 +03:00
parent 4773db83c5
commit a44d291cb5
3 changed files with 119 additions and 0 deletions
@@ -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)
})
})
+26
View File
@@ -352,6 +352,12 @@ export type SessionUIState = {
debugSessionMessages: (sessionId: string) => Promise<void>
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<SessionUIState>()((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
+11
View File
@@ -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)