fix(sync): avoid false session loading errors
New sessions fetched history before their confirmed empty state was published, and cold worktree selection ignored globally indexed session directories. Initialize new transcripts before navigation, reject stale-runtime creation responses, and resolve cold sessions through their known directory. Preserve earlier live records and explicit recovery. Validated 191 focused tests, UI type-check and ESLint. Reviewed dead-code and oxlint reports; existing anti-slop findings remain outside the added code. Remote and Windows runtime behavior has not been manually verified.
This commit is contained in:
@@ -202,6 +202,8 @@ Rules:
|
|||||||
10. Session-scoped ArrowUp and ArrowDown recall merges the visible transcript's user prompts (`useUserMessageHistory`) with the persisted input-history bucket for runtime + normalized directory + session identity. Revert markers hide prompts from the transcript source only; the persisted bucket still recalls them. Global scope reads the persisted runtime bucket alone.
|
10. Session-scoped ArrowUp and ArrowDown recall merges the visible transcript's user prompts (`useUserMessageHistory`) with the persisted input-history bucket for runtime + normalized directory + session identity. Revert markers hide prompts from the transcript source only; the persisted bucket still recalls them. Global scope reads the persisted runtime bucket alone.
|
||||||
11. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted.
|
11. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted.
|
||||||
|
|
||||||
|
A successful local session creation publishes its session record and calls `SessionMessageLoader.initializeCreatedSession` before selection starts navigation loading. The create response establishes an empty transcript only if no transcript has arrived yet. Initialization supersedes an earlier unresolved history load, preserves any messages or metadata received before the create response, and uses the server-returned directory. Opening that new session needs no history read; forced recovery and later eviction still use normal fetching. Creation responses from a previous runtime cannot select or initialize a session in the current runtime.
|
||||||
|
|
||||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
|
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
|
||||||
|
|
||||||
## Failed-turn diagnostics
|
## Failed-turn diagnostics
|
||||||
@@ -272,7 +274,7 @@ The discriminator is whether the server confirmed the path, not whether the valu
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
1. Ownership comes from the session record's own `directory`. `getSyncSessionDirectory()` reports *containment*, not ownership, and is only the fallback for a record without a directory: 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 reading ownership from membership routes a worktree session to its parent. `null` means "not indexed yet", never "no directory".
|
1. Ownership comes from the session record's own `directory`. When directory sync has no owning record yet, the global session index supplies that record's directory before local selection, worktree, or remembered hints. `getSyncSessionDirectory()` reports *containment*, not ownership, and is only the fallback for a record without a directory: 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 reading ownership from membership routes a worktree session to its parent. `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.
|
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.
|
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.
|
4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically.
|
||||||
|
|||||||
@@ -891,6 +891,7 @@ export async function createSession(
|
|||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
selectionTransition?: "submitted-draft",
|
selectionTransition?: "submitted-draft",
|
||||||
): Promise<Session | null> {
|
): Promise<Session | null> {
|
||||||
|
const runtimeKey = getRuntimeKey()
|
||||||
try {
|
try {
|
||||||
// Capture the effective directory used for session creation so we can fall
|
// Capture the effective directory used for session creation so we can fall
|
||||||
// back to it when the server response omits the `directory` field.
|
// back to it when the server response omits the `directory` field.
|
||||||
@@ -904,11 +905,22 @@ export async function createSession(
|
|||||||
metadata,
|
metadata,
|
||||||
}, effectiveDirectory)
|
}, effectiveDirectory)
|
||||||
|
|
||||||
|
if (getRuntimeKey() !== runtimeKey) return null
|
||||||
const sessionDirectory = (session as { directory?: string | null }).directory ?? effectiveDirectory ?? null
|
const sessionDirectory = (session as { directory?: string | null }).directory ?? effectiveDirectory ?? null
|
||||||
// Pre-populate routing index so SSE events arriving before session.created
|
// Pre-populate routing index so SSE events arriving before session.created
|
||||||
// can be routed to the correct child store
|
// can be routed to the correct child store
|
||||||
if (sessionDirectory) {
|
if (sessionDirectory) {
|
||||||
registerSessionDirectory(session.id, sessionDirectory)
|
registerSessionDirectory(session.id, sessionDirectory)
|
||||||
|
const store = _childStores?.ensureChild(sessionDirectory, { bootstrap: false })
|
||||||
|
if (store) {
|
||||||
|
const current = store.getState().session
|
||||||
|
const existing = Binary.search(current, session.id, (candidate) => candidate.id)
|
||||||
|
// An event may have published newer metadata before the create response.
|
||||||
|
if (!existing.found) {
|
||||||
|
store.setState({ session: [...current.slice(0, existing.index), session, ...current.slice(existing.index)] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getImperativeSessionMessageLoader()?.initializeCreatedSession({ directory: sessionDirectory, sessionID: session.id })
|
||||||
}
|
}
|
||||||
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
|
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
|
||||||
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
|
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { opencodeClient } from "@/lib/opencode/client"
|
||||||
|
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||||
|
import { ChildStoreManager } from "./child-store"
|
||||||
|
import { createSession, setActionRefs } from "./session-actions"
|
||||||
|
import { SessionMessageLoader, setImperativeSessionMessageLoader } from "./session-message-loader"
|
||||||
|
import { useSessionUIStore } from "./session-ui-store"
|
||||||
|
|
||||||
|
const originalCreateSession = opencodeClient.createSession
|
||||||
|
const originalDirectory = opencodeClient.getDirectory()
|
||||||
|
const originalSelection = useSessionUIStore.getState()
|
||||||
|
let childStores: ChildStoreManager
|
||||||
|
let loader: SessionMessageLoader
|
||||||
|
let requests = 0
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: "http://session-creation.test",
|
||||||
|
fetch: async () => {
|
||||||
|
requests += 1
|
||||||
|
return Response.json({ message: "not found" }, { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const session: Session = {
|
||||||
|
id: "session-created",
|
||||||
|
slug: "created",
|
||||||
|
projectID: "project-created",
|
||||||
|
directory: "C:/canonical/worktree",
|
||||||
|
title: "New session",
|
||||||
|
version: "1",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requests = 0
|
||||||
|
childStores = new ChildStoreManager()
|
||||||
|
loader = new SessionMessageLoader(childStores, { sdk, runtimeKey: getRuntimeKey() })
|
||||||
|
setActionRefs(sdk, childStores, () => "/requested")
|
||||||
|
setImperativeSessionMessageLoader(loader)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
opencodeClient.createSession = originalCreateSession
|
||||||
|
opencodeClient.setDirectory(originalDirectory)
|
||||||
|
useSessionUIStore.setState(originalSelection)
|
||||||
|
setImperativeSessionMessageLoader(null)
|
||||||
|
loader.dispose()
|
||||||
|
childStores.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("confirmed session creation", () => {
|
||||||
|
test("publishes the new transcript before navigation can issue a failing history read", async () => {
|
||||||
|
opencodeClient.createSession = async () => session
|
||||||
|
|
||||||
|
expect(await createSession(undefined, "/requested")).toBe(session)
|
||||||
|
const target = { directory: session.directory, sessionID: session.id }
|
||||||
|
await loader.ensure(target, { reason: "reactive" })
|
||||||
|
|
||||||
|
expect(requests).toBe(0)
|
||||||
|
expect(useSessionUIStore.getState().currentSessionDirectory).toBe(session.directory)
|
||||||
|
expect(childStores.getChild(session.directory)?.getState().session).toEqual([session])
|
||||||
|
expect(childStores.getChild(session.directory)?.getState().message[session.id]).toEqual([])
|
||||||
|
expect(loader.getSnapshot(target).status).toBe("ready")
|
||||||
|
expect(childStores.getChild("/requested")?.getState().message[session.id]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retains newer metadata and the first prompt delivered before the create response", async () => {
|
||||||
|
const store = childStores.ensureChild(session.directory, { bootstrap: false })
|
||||||
|
const newerSession = { ...session, title: "Already renamed", time: { created: 1, updated: 2 } }
|
||||||
|
const record = {
|
||||||
|
id: "msg_first",
|
||||||
|
sessionID: session.id,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 2 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", modelID: "test" },
|
||||||
|
} satisfies import("@opencode-ai/sdk/v2/client").UserMessage
|
||||||
|
opencodeClient.createSession = async () => {
|
||||||
|
store.setState({ session: [newerSession], message: { [session.id]: [record] } })
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession(undefined, "/requested")
|
||||||
|
|
||||||
|
expect(requests).toBe(0)
|
||||||
|
expect(store.getState().session).toEqual([newerSession])
|
||||||
|
expect(store.getState().message[session.id]).toEqual([record])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a rejected create does not seed an empty successful transcript", async () => {
|
||||||
|
opencodeClient.createSession = async () => { throw new Error("offline") }
|
||||||
|
const previousSelection = useSessionUIStore.getState().currentSessionId
|
||||||
|
|
||||||
|
expect(await createSession(undefined, "/requested")).toBeNull()
|
||||||
|
|
||||||
|
expect(useSessionUIStore.getState().currentSessionId).toBe(previousSelection)
|
||||||
|
expect(childStores.getChild(session.directory)).toBeUndefined()
|
||||||
|
expect(requests).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "bun:test"
|
|||||||
import { ChildStoreManager } from "./child-store"
|
import { ChildStoreManager } from "./child-store"
|
||||||
import { setSyncRefs } from "./sync-refs"
|
import { setSyncRefs } from "./sync-refs"
|
||||||
import { useSessionUIStore } from "./session-ui-store"
|
import { useSessionUIStore } from "./session-ui-store"
|
||||||
|
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selecting a session whose directory this client has not indexed yet routes it
|
* Selecting a session whose directory this client has not indexed yet routes it
|
||||||
@@ -39,6 +40,27 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("adoptAuthoritativeSessionDirectory", () => {
|
describe("adoptAuthoritativeSessionDirectory", () => {
|
||||||
|
test("opens a globally indexed Windows worktree session before its child store bootstraps", () => {
|
||||||
|
const sessionId = "ses_windows_global_directory"
|
||||||
|
useGlobalSessionsStore.getState().upsertSession({
|
||||||
|
id: sessionId,
|
||||||
|
slug: "windows-worktree",
|
||||||
|
projectID: "windows-project",
|
||||||
|
directory: "c:\\repo\\.worktrees\\feature",
|
||||||
|
title: "Worktree session",
|
||||||
|
version: "1",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
useSessionUIStore.getState().setCurrentSession(sessionId)
|
||||||
|
|
||||||
|
expect(useSessionUIStore.getState().currentSessionDirectory).toBe("C:/repo/.worktrees/feature")
|
||||||
|
expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("C:/repo/.worktrees/feature")
|
||||||
|
} finally {
|
||||||
|
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("promotes a guessed selection once the owning directory is indexed", () => {
|
test("promotes a guessed selection once the owning directory is indexed", () => {
|
||||||
useSessionUIStore.getState().setCurrentSession(SESSION_ID)
|
useSessionUIStore.getState().setCurrentSession(SESSION_ID)
|
||||||
expect(useSessionUIStore.getState().currentSessionDirectory).not.toBe(WORKTREE)
|
expect(useSessionUIStore.getState().currentSessionDirectory).not.toBe(WORKTREE)
|
||||||
|
|||||||
@@ -38,6 +38,72 @@ const createLoader = (messages: (input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SessionMessageLoader", () => {
|
describe("SessionMessageLoader", () => {
|
||||||
|
test("opens a confirmed new session without fetching history", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const { childStores, loader } = createLoader(async () => {
|
||||||
|
calls += 1
|
||||||
|
return { error: { message: "not found" }, response: { status: 404 } }
|
||||||
|
})
|
||||||
|
const target = { directory: "/created-repo", sessionID: "session-created" }
|
||||||
|
|
||||||
|
loader.initializeCreatedSession(target)
|
||||||
|
await loader.ensure(target, { reason: "navigation" })
|
||||||
|
await loader.ensure(target, { reason: "reactive" })
|
||||||
|
|
||||||
|
expect(calls).toBe(0)
|
||||||
|
expect(loader.getSnapshot(target)).toMatchObject({ status: "ready", resolved: true, complete: true })
|
||||||
|
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([])
|
||||||
|
|
||||||
|
const record = createRecord(target.sessionID)
|
||||||
|
loader.optimisticAdd({ ...target, message: record.info, parts: record.parts })
|
||||||
|
await loader.ensure(target)
|
||||||
|
expect(calls).toBe(0)
|
||||||
|
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info])
|
||||||
|
|
||||||
|
// Explicit recovery still reaches the server and exposes a real failure.
|
||||||
|
await loader.ensure(target, { force: true })
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
expect(loader.getSnapshot(target).status).toBe("error")
|
||||||
|
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info])
|
||||||
|
loader.dispose()
|
||||||
|
childStores.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creation supersedes an early history failure without losing the first prompt", async () => {
|
||||||
|
const pending = deferred<{ error: { message: string }; response: { status: number } }>()
|
||||||
|
const { childStores, loader } = createLoader(() => pending.promise)
|
||||||
|
const target = { directory: "/created-race", sessionID: "session-created" }
|
||||||
|
const earlyLoad = loader.ensure(target)
|
||||||
|
|
||||||
|
loader.initializeCreatedSession(target)
|
||||||
|
const record = createRecord(target.sessionID)
|
||||||
|
loader.optimisticAdd({ ...target, message: record.info, parts: record.parts })
|
||||||
|
pending.resolve({ error: { message: "not found" }, response: { status: 404 } })
|
||||||
|
await earlyLoad
|
||||||
|
|
||||||
|
expect(loader.getSnapshot(target).status).toBe("ready")
|
||||||
|
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info])
|
||||||
|
expect(childStores.getChild(target.directory)?.getState().part[record.info.id]).toEqual(record.parts)
|
||||||
|
loader.dispose()
|
||||||
|
childStores.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creation preserves messages and history coverage received before its response", async () => {
|
||||||
|
const record = createRecord("session-created")
|
||||||
|
const { childStores, loader } = createLoader(async () => response([record], "older-cursor"))
|
||||||
|
const target = { directory: "/created-events", sessionID: "session-created" }
|
||||||
|
await loader.ensure(target)
|
||||||
|
const before = childStores.getChild(target.directory)?.getState()
|
||||||
|
const coverage = loader.getSnapshot(target)
|
||||||
|
|
||||||
|
loader.initializeCreatedSession(target)
|
||||||
|
|
||||||
|
expect(childStores.getChild(target.directory)?.getState()).toBe(before)
|
||||||
|
expect(loader.getSnapshot(target)).toBe(coverage)
|
||||||
|
loader.dispose()
|
||||||
|
childStores.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
test("deduplicates navigation and reactive loading for the same target", async () => {
|
test("deduplicates navigation and reactive loading for the same target", async () => {
|
||||||
const pending = deferred<ReturnType<typeof response>>()
|
const pending = deferred<ReturnType<typeof response>>()
|
||||||
let calls = 0
|
let calls = 0
|
||||||
|
|||||||
@@ -176,6 +176,30 @@ export class SessionMessageLoader {
|
|||||||
this.disposed = false
|
this.disposed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeCreatedSession(target: SessionMessageTarget): void {
|
||||||
|
const normalized = this.normalizeTarget(target)
|
||||||
|
if (!normalized || this.disposed) return
|
||||||
|
const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false })
|
||||||
|
const current = store.getState()
|
||||||
|
// The create response establishes an empty transcript, but events or a
|
||||||
|
// prompt may already have materialized a newer snapshot while it travelled.
|
||||||
|
if (current.message[normalized.sessionID] !== undefined) return
|
||||||
|
const entry = this.getEntry(normalized)
|
||||||
|
this.bumpGeneration(entry)
|
||||||
|
entry.inflight = null
|
||||||
|
store.setState({ message: { ...current.message, [normalized.sessionID]: [] } })
|
||||||
|
this.patchEntry(entry, {
|
||||||
|
status: "ready",
|
||||||
|
loadingKind: null,
|
||||||
|
error: null,
|
||||||
|
resolved: true,
|
||||||
|
cursor: undefined,
|
||||||
|
complete: true,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
this.persistCoverage(normalized, entry.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
ensure(
|
ensure(
|
||||||
target: SessionMessageTarget,
|
target: SessionMessageTarget,
|
||||||
options?: { force?: boolean; reason?: "navigation" | "reactive" | "prefetch" },
|
options?: { force?: boolean; reason?: "navigation" | "reactive" | "prefetch" },
|
||||||
|
|||||||
@@ -526,6 +526,11 @@ const getAuthoritativeSessionDirectory = (sessionId: string): string | null => {
|
|||||||
const target = getAllSyncSessions().find((s) => s.id === sessionId)
|
const target = getAllSyncSessions().find((s) => s.id === sessionId)
|
||||||
const recordDirectory = target ? resolveDirectoryKey(target) : null
|
const recordDirectory = target ? resolveDirectoryKey(target) : null
|
||||||
if (recordDirectory) return normalizePath(recordDirectory)
|
if (recordDirectory) return normalizePath(recordDirectory)
|
||||||
|
// The sidebar can know this session before its directory store bootstraps.
|
||||||
|
// Use that record's own directory before falling back to local routing hints.
|
||||||
|
const globalSession = useGlobalSessionsStore.getState().entityById.get(sessionId)
|
||||||
|
const globalDirectory = normalizePath(globalSession?.directory)
|
||||||
|
if (globalDirectory) return globalDirectory
|
||||||
const owningDirectory = getSyncSessionDirectory(sessionId)
|
const owningDirectory = getSyncSessionDirectory(sessionId)
|
||||||
return owningDirectory ? normalizePath(owningDirectory) : null
|
return owningDirectory ? normalizePath(owningDirectory) : null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user