fix(sync): read session ownership from the record, not store membership

A session created in a git worktree while the client was already running did
not render: the message list stayed empty while the prompt and the assistant
reply were both present in the session, visible on any fresh load. Reported as
prompting in a worktree sometimes not working.

Ownership was read from which child store holds the session. That is
containment, not ownership. A project's session list includes the sessions of
its worktrees so the sidebar can group them, so the parent repository holds
worktree sessions too, and whichever store bootstrapped first won. Captured
mid-failure, the two signals disagreed outright:

  owningDirectory  /repo                      <- parent, merely holds it
  recordDirectory  /repo/.worktrees/feature   <- the session's own directory

The parent won, so every fetch was addressed to a directory that does not own
the session, the session id resolved to undefined there, and the requests
failed as /api/session/undefined in a retry loop. The session's own record is
now believed; store membership remains the fallback for a record that carries
no directory.

This also explains why the previous commit alone was not enough: settling the
guessed directory adopted this same wrong value and then cleared the guess,
which prevented any later correction.

Verified against the reproduction rather than by reasoning. Before: three of
four runs never rendered. After, on a clean build with the instrumentation
removed: three of three rendered the reply live, each routed to its own
worktree. Tests cover ownership disagreeing with containment, plus both
directions of the guess promotion.
This commit is contained in:
Bohdan Triapitsyn
2026-08-04 01:27:18 +03:00
parent a44d291cb5
commit bf3186c679
2 changed files with 34 additions and 5 deletions
@@ -19,10 +19,14 @@ const PARENT = "/repo"
const WORKTREE = "/repo/.worktrees/feature"
const SESSION_ID = "ses_directory_adoption"
const indexSessionIn = (manager: ChildStoreManager, directory: string): void => {
const indexSessionIn = (
manager: ChildStoreManager,
directory: string,
recordDirectory: string = directory,
): void => {
const store = manager.ensureChild(directory, { bootstrap: false })
store.setState({
session: [{ id: SESSION_ID, directory, title: "test" } as never],
session: [{ id: SESSION_ID, directory: recordDirectory, title: "test" } as never],
})
}
@@ -45,6 +49,18 @@ describe("adoptAuthoritativeSessionDirectory", () => {
expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE)
})
test("believes the session record over the store that merely holds it", () => {
// A project's session list includes the sessions of its worktrees so the
// sidebar can group them, so the parent store holds this session while the
// session itself reports the worktree. Ownership comes from the record.
useSessionUIStore.getState().setCurrentSession(SESSION_ID)
indexSessionIn(manager, PARENT, 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
+16 -3
View File
@@ -411,11 +411,24 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW
* 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.
*/
/**
* The directory that owns a session, from the two server-backed signals.
*
* The session's own record wins. Holding a session in a child store proves
* containment, not ownership: a project's session list legitimately includes
* the sessions of its worktrees so the sidebar can group them, so the parent
* repository holds worktree sessions too. Reading ownership from store
* membership therefore reports the parent for a session that lives in a
* worktree, and every fetch is then addressed to a directory that does not own
* it. Store membership remains the fallback for a session whose record carries
* no directory.
*/
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
const recordDirectory = target ? resolveDirectoryKey(target) : null
if (recordDirectory) return normalizePath(recordDirectory)
const owningDirectory = getSyncSessionDirectory(sessionId)
return owningDirectory ? normalizePath(owningDirectory) : null
}
/**