From 98a2e27cda92f5ee6550b5872b67aec15a6a0d73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:56:25 -0700 Subject: [PATCH 01/21] fix(native-chat): report a failed startup chat reconcile instead of failing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none). --- .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-host.ts | 13 +- ...uctured-agent-session-restart-reconcile.ts | 16 ++ ...-session-startup-reconcile-failure.test.ts | 145 ++++++++++++++++++ .../structured-agent-session-runtime.ts | 7 + ...ed-agent-session-startup-reconcile.test.ts | 110 +++++++++++++ 6 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts create mode 100644 src/main/runtime/structured-agent-session-startup-reconcile.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 80ea85e86835..bb858f804838 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -120,6 +120,9 @@ export type StructuredAgentSessionHostDeps = { /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + /** A startup reconcile that refused or threw. Startup carries on: every attach, send and read + * reconciles its own chat again. */ + onStartupReconcileFailure?: (failure: unknown) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ onSessionStatusChanged?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index efdd83d9f146..56f206db1a8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -10,7 +10,10 @@ import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { createRestartReconciler } from './structured-agent-session-restart-reconcile' +import { + createRestartReconciler, + reconcileLeasesAtStartup +} from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' @@ -228,12 +231,8 @@ export class StructuredAgentSessionHost { } } - reconcileRestartLeases = async (): Promise => { - const refusal = await this.reconcileLeases('startup') - if (refusal) { - throw new Error(refusal.code) - } - } + reconcileRestartLeases = (): Promise => + reconcileLeasesAtStartup(this.reconcileLeases, this.deps.onStartupReconcileFailure) restoreReadableSessions = (sessionIds?: readonly string[]): Promise => this.restore.restoreReadableSessions(sessionIds) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 3b6cc30271e5..b4d1380090d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,6 +45,22 @@ export function createRestartReconciler(deps: { } } +/** Startup never fails on this verdict: a lease left unreconciled grants no writer, and the next + * attach, send or read of that chat reconciles it again. So a failure is reported, never owed. */ +export async function reconcileLeasesAtStartup( + reconcile: (sessionId: string) => Promise, + onFailure: ((failure: unknown) => void) | undefined +): Promise { + try { + const refusal = await reconcile('startup') + if (refusal) { + onFailure?.(refusal) + } + } catch (error) { + onFailure?.(error) + } +} + async function reconcileCurrentLeases(deps: { store: AgentSessionRecordStore probe: (record: AgentSessionRecord) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts new file mode 100644 index 000000000000..0e96294872bc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -0,0 +1,145 @@ +// The startup reconcile is bookkeeping: a lock that gives up or a store this build may not write +// is reported, and startup carries on. Nothing is owed after it, because every chat reconciles its +// own lease again when it is sent to or read. + +import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type * as FileTransactionLock from '../../file-transaction-lock' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from '../../runtime/agent-session-record-store-file' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const lock = vi.hoisted(() => ({ failing: false })) + +vi.mock('../../file-transaction-lock', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + withFileTransactionLock: (...args: Parameters) => + lock.failing + ? // What proper-lockfile throws once its retries give up. + Promise.reject( + Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) + ) + : actual.withFileTransactionLock(...args) + } +}) + +const relaunchedRoots: string[] = [] + +afterEach(async () => { + lock.failing = false + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A host with one chat, relaunched over a copy of its files; `rewrite` edits the copied store. */ +async function relaunch(rewrite?: (json: Record) => void) { + const dying = hostTestState() + await attach() + // An empty renewal queues behind every record write, so they are on disk. + await dying.store.renewLeases([]) + const relaunched = `${dying.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(dying.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const storeDirectory = join(relaunched, 'store') + if (rewrite) { + const path = agentSessionStorePath(storeDirectory) + const json = JSON.parse(await readFile(path, 'utf-8')) + rewrite(json) + await writeFile(path, JSON.stringify(json)) + } + const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) + const onStartupReconcileFailure = vi.fn() + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW, + onStartupReconcileFailure + }) + replaceHostTestState({ store, host }) + return { host, store, storeDirectory, onStartupReconcileFailure } +} + +it('reports a startup reconcile whose store write fails, and does not reject', async () => { + const { host, store, onStartupReconcileFailure } = await relaunch() + + lock.failing = true + await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() + + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onStartupReconcileFailure).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ELOCKED' }) + ) + // Nothing was adjudicated, so the lease still grants no writer. + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) +}) + +it('reconciles the chat on its next send once the store can be written again', async () => { + const { host, store, onStartupReconcileFailure } = await relaunch() + lock.failing = true + await host.reconcileRestartLeases() + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + lock.failing = false + + const body = hostTestMessage('sent after a startup reconcile failed') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: true }) + + // The send is queued; delivering it starts the agent, and that start reconciles the lease. + await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalledOnce(), { + timeout: 10_000 + }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + // Before the relaunched directory is removed, so the child's wind-down can write its lease. + await host.flushAllStreamedEvents() +}) + +it('reports a store a newer Orca wrote without writing it, and does not reject', async () => { + const { host, store, storeDirectory, onStartupReconcileFailure } = await relaunch((json) => { + json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 + }) + expect(store.readOnly).toBe(true) + const path = agentSessionStorePath(storeDirectory) + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + + await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() + + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onStartupReconcileFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: 'agent_session_legacy_required' }) + ) + expect(store.listRecords().map((record) => record.sessionId)).toEqual([SESSION]) + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 24735c721cc2..9cdb980684f8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -317,6 +317,13 @@ async function installOnJournal( : {}), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + onStartupReconcileFailure: (error) => + deps.onError + ? deps.onError({ scope: 'structured-agent-session-startup-reconcile', error }) + : console.warn( + '[structured-agent-session] reconciling chat leases at startup failed', + error + ), ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts new file mode 100644 index 000000000000..4b444eef70e1 --- /dev/null +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -0,0 +1,110 @@ +// A profile whose chat records a newer Orca wrote opens read-only. The startup reconcile cannot +// write there, which is bookkeeping: startup must still finish rather than put the whole app into +// its degraded "Session restore failed" mode, and the file must be left exactly as it was. + +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from './agent-session-record-store-file' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-startup-reconcile-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** A profile whose record store a newer Orca wrote, with one chat in it. */ +async function seedNewerStore() { + const storeDirectory = join(root, 'agent-sessions') + const path = agentSessionStorePath(storeDirectory) + const record = agentSessionRecordFixture() + await mkdir(storeDirectory, { recursive: true }) + await writeFile( + path, + JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, + hostId: 'local', + records: { [record.sessionId]: record }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + ) + return { storeDirectory, path, sessionId: record.sessionId } +} + +function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + ...(onError ? { onError } : {}) + }) + internal.refreshMobileSessionPtyRecords = async () => new Set() + return runtime +} + +it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { + const { storeDirectory, path, sessionId } = await seedNewerStore() + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + const onError = vi.fn() + const runtime = startupRuntime(onError) + + // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-startup-reconcile', + error: expect.objectContaining({ message: 'agent_session_legacy_required' }) + }) + expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') + await stopStructuredAgentSessionRuntime() + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) +}) + +// The desktop installs its host with no error sink, so the failure is logged rather than dropped. +it('logs the failure when the host has no error sink', async () => { + await seedNewerStore() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect( + startupRuntime().prepareStructuredAgentSessionStartupRestoration() + ).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] reconciling chat leases at startup failed', + expect.objectContaining({ message: 'agent_session_legacy_required' }) + ) +}) From 872f31f915ccff1766d38c30430b5038cc605714 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:12:13 -0700 Subject: [PATCH 02/21] fix(native-chat): read restored chats without waiting on lease bookkeeping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads. --- .../structured-agent-session-host-types.ts | 6 +- .../structured-agent-session-host.ts | 10 +- ...ed-agent-session-readable-restorer.test.ts | 2 +- ...uctured-agent-session-restart-reconcile.ts | 46 +++-- ...ured-agent-session-restart-restore.test.ts | 8 +- ...tructured-agent-session-restart-restore.ts | 7 +- ...ent-session-restore-without-import.test.ts | 2 +- .../structured-agent-session-reveal.ts | 18 +- ...-session-startup-reconcile-failure.test.ts | 59 +++++-- ...tore-structured-agent-session-tabs-once.ts | 16 +- ...runtime-structured-session-restore.test.ts | 3 +- .../structured-agent-session-runtime.ts | 9 +- ...ed-agent-session-startup-reconcile.test.ts | 160 ++++++++++++++++-- 13 files changed, 270 insertions(+), 76 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index bb858f804838..e2e8e07d4fb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -120,9 +120,9 @@ export type StructuredAgentSessionHostDeps = { /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - /** A startup reconcile that refused or threw. Startup carries on: every attach, send and read - * reconciles its own chat again. */ - onStartupReconcileFailure?: (failure: unknown) => void + /** A lease reconcile run for startup or a read that refused or threw, once per distinct failure. + * Startup and the read carry on: the next attach or send reconciles every lease again. */ + onLeaseReconcileFailure?: (failure: unknown) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ onSessionStatusChanged?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 56f206db1a8d..78bc7de155ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -10,10 +10,7 @@ import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { - createRestartReconciler, - reconcileLeasesAtStartup -} from './structured-agent-session-restart-reconcile' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' @@ -138,7 +135,7 @@ export class StructuredAgentSessionHost { flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { - reconcile: this.reconcileLeases, + reconcileLeases: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: this.hasSession, @@ -231,8 +228,7 @@ export class StructuredAgentSessionHost { } } - reconcileRestartLeases = (): Promise => - reconcileLeasesAtStartup(this.reconcileLeases, this.deps.onStartupReconcileFailure) + reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() restoreReadableSessions = (sessionIds?: readonly string[]): Promise => this.restore.restoreReadableSessions(sessionIds) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 5eaedfffb384..240882f17564 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -39,7 +39,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { adapter: {} }, supportsRecord: () => true, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index b4d1380090d5..c9cba45aa59d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,20 +45,46 @@ export function createRestartReconciler(deps: { } } -/** Startup never fails on this verdict: a lease left unreconciled grants no writer, and the next - * attach, send or read of that chat reconciles it again. So a failure is reported, never owed. */ -export async function reconcileLeasesAtStartup( +/** The reconcile a reader runs, at startup and before each restored read: it never throws, since + * an unreconciled lease grants no writer and the next send reconciles again before it acts. + * Answers whether every lease is settled; a failure is reported once until a reconcile settles. */ +export function createReaderReconcile( reconcile: (sessionId: string) => Promise, onFailure: ((failure: unknown) => void) | undefined -): Promise { - try { - const refusal = await reconcile('startup') - if (refusal) { - onFailure?.(refusal) +): (sessionId: string) => Promise { + let reported: string | null = null + return async (sessionId) => { + let failure: unknown + try { + const refusal = await reconcile(sessionId) + if (!refusal) { + reported = null + return true + } + failure = refusal + } catch (error) { + failure = error + } + // Every read re-runs a failing reconcile, so each distinct failure is reported once. + const key = failureKey(failure) + if (key !== reported) { + reported = key + onFailure?.(failure) + } + return false + } +} + +function failureKey(failure: unknown): string { + if (typeof failure === 'object' && failure !== null) { + if ('code' in failure && failure.code) { + return String(failure.code) + } + if ('message' in failure) { + return String(failure.message) } - } catch (error) { - onFailure?.(error) } + return String(failure) } async function reconcileCurrentLeases(deps: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index c7d04ea64cc4..3bf000f9e409 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -62,7 +62,7 @@ describe('restart journal restoration', () => { const restoration = restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, records, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, @@ -103,7 +103,7 @@ describe('restart journal restoration', () => { await restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, records, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, @@ -150,7 +150,7 @@ describe('restart journal restoration', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => { calls.push('resolveRecovery') }, @@ -174,7 +174,7 @@ describe('restart journal restoration', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 4e4b7f1e5ddd..02561594caa8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -13,7 +13,6 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { @@ -28,7 +27,8 @@ export type StructuredAgentSessionReadRestoreDeps = { openDeps: StructuredAgentSessionConversationOpenDeps & { store: Pick } - reconcile: (sessionId: string) => Promise + /** Whether every lease is settled. Never throws: a read grants no writer, so bookkeeping can't block it. */ + reconcile: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean @@ -43,8 +43,7 @@ async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, sessionId: string ): Promise { - const unreconciled = await input.reconcile(sessionId) - if (!unreconciled) { + if (await input.reconcile(sessionId)) { // A session latched in recovery exits here at startup, without waiting for a client. await input.resolveRecovery(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 226e6536c9c1..1308dbfe4a83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -232,7 +232,7 @@ async function restore(sessionIds: readonly string[]) { await restoreStructuredAgentSessionsOnRestart({ openDeps: deps, records: sessionIds.map(recordFor), - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: (sessionId) => sessions.has(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 2a1899ab1ccd..96b0abd2b113 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -10,10 +10,12 @@ // a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a // Retry, so the tab is worth publishing either way. +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' +import { createReaderReconcile } from './structured-agent-session-restart-reconcile' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal @@ -51,23 +53,31 @@ export async function revealStructuredAgentSession( } } -/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. */ +/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. + * Its reconcile is a reader's, which never fails a read or startup; startup runs the same one. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' - > + 'openDeps' | 'supportsRecord' | 'reconcile' + > & { reconcileLeases: (sessionId: string) => Promise } ): { + reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { + const { reconcileLeases, ...rest } = wiring + const reconcile = createReaderReconcile(reconcileLeases, deps.onLeaseReconcileFailure) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - ...wiring + reconcile, + ...rest }) const gate = new StructuredAgentSessionRestartRestoreGate() return { + reconcileRestartLeases: async () => { + await reconcile('startup') + }, restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 0e96294872bc..5e1c8a95868c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -1,6 +1,6 @@ -// The startup reconcile is bookkeeping: a lock that gives up or a store this build may not write -// is reported, and startup carries on. Nothing is owed after it, because every chat reconciles its -// own lease again when it is sent to or read. +// The lease reconcile is bookkeeping: a lock that gives up or a store this build may not write is +// reported, and startup and every read carry on. Nothing is owed after it, because an unreconciled +// lease grants no writer and the next send reconciles every lease again before it acts. import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -73,7 +73,7 @@ async function relaunch(rewrite?: (json: Record) => void) { await writeFile(path, JSON.stringify(json)) } const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) - const onStartupReconcileFailure = vi.fn() + const onLeaseReconcileFailure = vi.fn() const host = new StructuredAgentSessionHost({ store, adapter: adapter(), @@ -82,31 +82,29 @@ async function relaunch(rewrite?: (json: Record) => void) { mintSpawnToken: () => 'spawn-next', probeOwner: async () => ({ outcome: 'pid-absent' }), now: () => NOW, - onStartupReconcileFailure + onLeaseReconcileFailure }) replaceHostTestState({ store, host }) - return { host, store, storeDirectory, onStartupReconcileFailure } + return { host, store, storeDirectory, onLeaseReconcileFailure } } it('reports a startup reconcile whose store write fails, and does not reject', async () => { - const { host, store, onStartupReconcileFailure } = await relaunch() + const { host, store, onLeaseReconcileFailure } = await relaunch() lock.failing = true await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() - expect(onStartupReconcileFailure).toHaveBeenCalledWith( - expect.objectContaining({ code: 'ELOCKED' }) - ) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) // Nothing was adjudicated, so the lease still grants no writer. expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) }) it('reconciles the chat on its next send once the store can be written again', async () => { - const { host, store, onStartupReconcileFailure } = await relaunch() + const { host, store, onLeaseReconcileFailure } = await relaunch() lock.failing = true await host.reconcileRestartLeases() - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() lock.failing = false const body = hostTestMessage('sent after a startup reconcile failed') @@ -119,13 +117,13 @@ it('reconciles the chat on its next send once the store can be written again', a timeout: 10_000 }) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() // Before the relaunched directory is removed, so the child's wind-down can write its lease. await host.flushAllStreamedEvents() }) it('reports a store a newer Orca wrote without writing it, and does not reject', async () => { - const { host, store, storeDirectory, onStartupReconcileFailure } = await relaunch((json) => { + const { host, store, storeDirectory, onLeaseReconcileFailure } = await relaunch((json) => { json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 }) expect(store.readOnly).toBe(true) @@ -135,11 +133,38 @@ it('reports a store a newer Orca wrote without writing it, and does not reject', await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() - expect(onStartupReconcileFailure).toHaveBeenCalledWith( + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith( expect.objectContaining({ message: 'agent_session_legacy_required' }) ) expect(store.listRecords().map((record) => record.sessionId)).toEqual([SESSION]) expect(await readFile(path)).toEqual(bytes) expect(await readdir(storeDirectory)).toEqual(files) }) + +it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch() + lock.failing = true + await host.reconcileRestartLeases() + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) +}) + +it('restores a chat for reading from a store a newer Orca wrote', async () => { + const { host, storeDirectory, onLeaseReconcileFailure } = await relaunch((json) => { + json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 + }) + const path = agentSessionStorePath(storeDirectory) + const bytes = await readFile(path) + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(await readFile(path)).toEqual(bytes) +}) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 8d29a0d360b9..476b07820552 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -83,7 +83,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu agent: session.agent, sessionId, activate: false, - notify: false + notify: false, + visibilityWriteMayFail: true }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable @@ -105,14 +106,25 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu replacesSessionId?: string /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string + /** A restore's republish: a failed visibility write is logged and the tab still publishes, + * since a client drops every unpublished chat tab. */ + visibilityWriteMayFail?: boolean }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility( + const written = host.setSessionTabVisibility( input.sessionId, true, ...(input.tabId ? [input.tabId] : []) ) + await (input.visibilityWriteMayFail + ? written.catch((error) => + console.warn('[structured-agent-session] recording a restored chat tab failed', { + sessionId: input.sessionId, + error + }) + ) + : written) } const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 1d8105d07f81..80b8e4f3be65 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -356,7 +356,8 @@ describe('structured session cold restoration', () => { sessionId: 'restored-claude', agent: 'claude', activate: false, - notify: false + notify: false, + visibilityWriteMayFail: true }) const restored = await runtime.listMobileSessionTabs('id:workspace-1') diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 9cdb980684f8..0c6b392970d4 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -317,13 +317,10 @@ async function installOnJournal( : {}), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), - onStartupReconcileFailure: (error) => + onLeaseReconcileFailure: (error) => deps.onError - ? deps.onError({ scope: 'structured-agent-session-startup-reconcile', error }) - : console.warn( - '[structured-agent-session] reconciling chat leases at startup failed', - error - ), + ? deps.onError({ scope: 'structured-agent-session-lease-reconcile', error }) + : console.warn('[structured-agent-session] reconciling chat leases failed', error), ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 4b444eef70e1..8fecec36a2ce 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -5,8 +5,15 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type * as FileTransactionLock from '../file-transaction-lock' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -18,6 +25,22 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +const lock = vi.hoisted(() => ({ failing: false })) + +vi.mock('../file-transaction-lock', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + withFileTransactionLock: (...args: Parameters) => + lock.failing + ? // What proper-lockfile throws once its retries give up. + Promise.reject( + Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) + ) + : actual.withFileTransactionLock(...args) + } +}) + let root: string beforeEach(async () => { @@ -25,13 +48,16 @@ beforeEach(async () => { }) afterEach(async () => { + lock.failing = false await stopStructuredAgentSessionRuntime().catch(() => undefined) vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) -/** A profile whose record store a newer Orca wrote, with one chat in it. */ -async function seedNewerStore() { +const PROMPT = 'add a retry' + +/** A profile's record store with one chat in it; one a newer Orca wrote opens read-only here. */ +async function seedStore(options: { newer: boolean; visible?: boolean }) { const storeDirectory = join(root, 'agent-sessions') const path = agentSessionStorePath(storeDirectory) const record = agentSessionRecordFixture() @@ -39,28 +65,61 @@ async function seedNewerStore() { await writeFile( path, JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), hostId: 'local', records: { [record.sessionId]: record }, operations: {}, retiredClaimKeys: [], - unusableRecords: {} + unusableRecords: {}, + ...(options.visible ? { visibleSessionIds: [record.sessionId] } : {}) }) ) - return { storeDirectory, path, sessionId: record.sessionId } + return { storeDirectory, path, record, sessionId: record.sessionId } } -function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { +/** The chat's history as the last run left it: one prompt, accepted. */ +async function seedHistory(record: AgentSessionRecord): Promise { + const fence = record.lease.runtimeFence + const database = JournalHostDatabase.open(root) + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: fence }) + ), + database + }) + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence, + handoverRecorded: true + }) + await journal.resolveDispatch({ + clientMessageId: 'client-1', + fence, + state: 'accepted', + providerIdentity: null + }) + await journal.close() + database.close() +} + +function startupRuntime( + onError?: (input: { scope: string; error: unknown }) => void, + afterInstall?: () => void +) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean ensureStructuredAgentSessionHost(): Promise refreshMobileSessionPtyRecords(): Promise | null> + mobileSessionTabsByWorktree: Map } internal.hasPersistedStructuredAgentSessionStore = () => true - internal.ensureStructuredAgentSessionHost = () => - ensureStructuredAgentSessionHost({ + internal.ensureStructuredAgentSessionHost = async () => { + const installed = await ensureStructuredAgentSessionHost({ stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -69,23 +128,26 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), ...(onError ? { onError } : {}) }) + afterInstall?.() + return installed + } internal.refreshMobileSessionPtyRecords = async () => new Set() - return runtime + return { runtime, publishedTabs: () => internal.mobileSessionTabsByWorktree.get('workspace-1') } } it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { - const { storeDirectory, path, sessionId } = await seedNewerStore() + const { storeDirectory, path, sessionId } = await seedStore({ newer: true }) const bytes = await readFile(path) const files = await readdir(storeDirectory) const onError = vi.fn() - const runtime = startupRuntime(onError) + const { runtime } = startupRuntime(onError) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() expect(onError).toHaveBeenCalledOnce() expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-startup-reconcile', + scope: 'structured-agent-session-lease-reconcile', error: expect.objectContaining({ message: 'agent_session_legacy_required' }) }) expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') @@ -96,15 +158,81 @@ it('finishes startup over records a newer Orca wrote, reports it, and writes not // The desktop installs its host with no error sink, so the failure is logged rather than dropped. it('logs the failure when the host has no error sink', async () => { - await seedNewerStore() + await seedStore({ newer: true }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect( - startupRuntime().prepareStructuredAgentSessionStartupRestoration() + startupRuntime().runtime.prepareStructuredAgentSessionStartupRestoration() ).resolves.toBeUndefined() expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] reconciling chat leases at startup failed', + '[structured-agent-session] reconciling chat leases failed', expect.objectContaining({ message: 'agent_session_legacy_required' }) ) }) + +// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), +// which reads every chat whose tab was open at quit. That read must not wait on the lease bookkeeping. +describe('restoring the chat tabs open at quit', () => { + async function expectChatRestored( + runtime: OrcaRuntimeService, + publishedTabs: () => RuntimeMobileSessionTabsSnapshot | undefined, + sessionId: string + ) { + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + expect(publishedTabs()?.tabs.map((tab) => tab.id)).toEqual([`agent-session:${sessionId}`]) + const host = getStructuredAgentSessionHost() + expect(JSON.stringify((await host!.journalSnapshot(sessionId)).items)).toContain(PROMPT) + } + + /** The reconcile failure once, though startup and the read both ran it; the tab write per chat. */ + function expectReported( + onError: ReturnType, + warn: ReturnType, + sessionId: string, + failure: Record + ) { + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining(failure) + }) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording a restored chat tab failed', + { sessionId, error: expect.objectContaining(failure) } + ) + } + + it('lists and reads a chat from records a newer Orca wrote, and writes nothing', async () => { + const { storeDirectory, path, record } = await seedStore({ newer: true, visible: true }) + await seedHistory(record) + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + const onError = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, publishedTabs } = startupRuntime(onError) + + await expectChatRestored(runtime, publishedTabs, record.sessionId) + + expectReported(onError, warn, record.sessionId, { message: 'agent_session_legacy_required' }) + await stopStructuredAgentSessionRuntime() + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) + }) + + it('lists and reads a chat while the record store lock keeps failing', async () => { + const { record } = await seedStore({ newer: false, visible: true }) + await seedHistory(record) + const onError = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // Free when the store opens, then held for good: every later write gives up. + const { runtime, publishedTabs } = startupRuntime(onError, () => { + lock.failing = true + }) + + await expectChatRestored(runtime, publishedTabs, record.sessionId) + + expectReported(onError, warn, record.sessionId, { code: 'ELOCKED' }) + lock.failing = false + }) +}) From e5d1ab2563f1c5b3e01d889d87af0f3546edc520 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:48:43 -0700 Subject: [PATCH 03/21] fix(native-chat): list chat tabs without opening every chat, and stop startup waiting on the lease check At startup the chat tab list, and the window's first paint behind it, waited for two things: the startup lease check over every saved chat, and opening and replaying the full history of every listed chat. The list itself was then taken from the chats that had opened, so a chat whose history was missing or could not be opened lost its tab after a restart, and one failed open or lease check failed the whole list. Now the tab list comes from the durable records and the tab table, in tab order, filtered to chats this host serves. It opens nothing. Each chat's history is opened after the list is published, by the same restore pass as before, not awaited; a failure there is logged and leaves the list alone. The startup lease check is started and never awaited: neither the list nor paint needs leases. Two things did rely on the startup wait: - a write to a chat whose lease is still the previous run's. Every write (send, Stop, prompt answer, option, goal, rewind, clear, compact, queued drafts, restart-offer resume) enters its chat's queue through the lease check now: it waits for that chat's check, or runs it, when the lease is unreconciled, and enqueues at once otherwise. Attach and agent start already ran the check themselves. - the structured worker release, which the renderer runs right after the startup step. It now runs the check itself before it observes the worker. No schema change, no saved status. Status rows now arrive per chat just after paint instead of all at paint. --- .../structured-agent-session-host-tabs.ts | 33 ++- .../structured-agent-session-host.test.ts | 2 +- .../structured-agent-session-host.ts | 9 +- ...gent-session-persisted-tab-listing.test.ts | 122 +++++++++ ...red-agent-session-provider-restore.test.ts | 2 +- ...ctured-agent-session-refusal-retry.test.ts | 5 + ...uctured-agent-session-restart-reconcile.ts | 19 ++ ...ent-session-restored-status-parity.test.ts | 89 +++++++ ...-agent-session-startup-listing-test-rig.ts | 242 +++++++++++++++++ ...red-agent-session-surface-lifetime.test.ts | 2 +- ...red-agent-session-write-lease-wait.test.ts | 109 ++++++++ ...structured-agent-session-create-support.ts | 18 +- ...tore-structured-agent-session-tabs-once.ts | 18 +- ...runtime-structured-session-restore.test.ts | 7 +- .../worker/worker-release-completion.ts | 6 +- ...orker-release-structured-reconcile.test.ts | 62 +++++ .../structured-worker-tab-retirement.test.ts | 2 + ...ed-agent-session-startup-reconcile.test.ts | 11 +- ...-agent-session-tab-listing-startup.test.ts | 245 ++++++++++++++++++ 19 files changed, 968 insertions(+), 35 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts create mode 100644 src/main/runtime/structured-agent-session-tab-listing-startup.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 8a18040daf35..ee13814b485b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' /** * Chat-tab visibility is the deletion funnel: every path that removes a chat as a user-facing @@ -37,15 +38,27 @@ export type StructuredAgentSessionTab = { agent: AgentSessionRecord['provider'] } -export function listStructuredAgentSessionTabs( - sessions: ReadonlyMap< - string, - { params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } } - > +/** Tabs from durable state alone: each requested id, once, in the order given, that has a record + * this host serves. Opens nothing: a chat whose history is missing or unreadable keeps its tab and + * explains itself when read. */ +export function listPersistedSessionTabs( + deps: { + store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + adapter: Parameters[0] + }, + sessionIds: readonly string[] ): StructuredAgentSessionTab[] { - return [...sessions.entries()].map(([sessionId, session]) => ({ - sessionId, - workspaceId: session.params.location.workspaceId, - agent: session.params.provider - })) + const tabs = new Map() + for (const sessionId of sessionIds) { + const record = deps.store.getRecord(sessionId) + if (!record || tabs.has(sessionId) || !adapterSupportsRecord(deps.adapter, record)) { + continue + } + tabs.set(sessionId, { + sessionId, + workspaceId: record.location.workspaceId, + agent: record.provider + }) + } + return [...tabs.values()] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 590f69c79840..3bb69fc16b7f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -610,7 +610,7 @@ describe('restart', () => { const restoreReads = listRecords.mock.calls.length await host.restoreReadableSessions() - expect(host.listSessionTabs()).toEqual([ + expect(host.listSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) const history = await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 56f206db1a8d..f0a3ad6ca450 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -12,7 +12,8 @@ import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { createRestartReconciler, - reconcileLeasesAtStartup + reconcileLeasesAtStartup, + serializeAfterLeaseCheck } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' @@ -215,7 +216,8 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) - listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) + /** From the record store and the given tab ids; opens no conversation. */ + listSessionTabs = (ids: readonly string[]) => sessionTabs.listPersistedSessionTabs(this.deps, ids) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) @@ -274,7 +276,8 @@ export class StructuredAgentSessionHost { publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, conversation: this.lifetime.conversation, - serialize: (sessionId, task) => this.serialize(sessionId, task), + // Every write funnels through here, so none acts on a lease the startup check has not reached. + serialize: serializeAfterLeaseCheck(this.deps.store, this.reconcileLeases, this.serialize), openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts new file mode 100644 index 000000000000..f14a53ee673a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts @@ -0,0 +1,122 @@ +// A restarted host lists a chat's tab from its record and the tab table, never from whether its +// history opened: a chat with no history on disk, and one whose history cannot be read, both keep +// their tab. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' +import { + createStartupRig, + type StartupRig +} from './structured-agent-session-startup-listing-test-rig' + +let rig: StartupRig + +beforeEach(async () => { + rig = await createStartupRig() +}) + +afterEach(async () => { + await rig.dispose() + closeTestJournalHostDatabases() + vi.restoreAllMocks() +}) + +const tab = (sessionId: string, workspaceId = 'workspace-1') => ({ + sessionId, + workspaceId, + agent: 'codex' +}) + +describe('the tab list after a restart', () => { + it('lists every chat it is given without opening one', async () => { + await rig.chat('session-a', { message: 'one' }) + await rig.chat('session-b', { message: 'two' }) + await rig.crash() + const host = await rig.boot() + + expect(host.listSessionTabs(['session-a', 'session-b'])).toEqual([ + tab('session-a'), + tab('session-b') + ]) + expect(rig.historyFilePath).not.toHaveBeenCalled() + }) + + it('keeps a chat with no history on disk, which then reads empty and takes a send (T3)', async () => { + // Its first start failed before the conversation opened, so no history was ever written. + rig.acquire.mockRejectedValueOnce(new Error('spawn failed')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await expect(rig.chat('session-new')).rejects.toThrow(/attach refused/) + await rig.store.setSessionTabVisibility('session-new', true) + await rig.chat('session-old', { message: 'hello' }) + await rig.crash() + const host = await rig.boot() + const ids = ['session-new', 'session-old'] + + await host.restoreReadableSessions(ids) + + expect(host.listSessionTabs(ids)).toEqual([tab('session-new'), tab('session-old')]) + // The restore founds no history for it; the tab stands anyway. + expect(rig.opensOf('session-new')).toBe(0) + const page = await host.history({ sessionId: 'session-new', direction: 'tail' }) + expect(page.ok && page.page.items).toEqual([]) + const sent = await rig.send('session-new', 'first words') + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.dispatch).toHaveBeenCalledOnce()) + const after = await host.history({ sessionId: 'session-new', direction: 'tail' }) + expect(JSON.stringify(after.ok && after.page)).toContain('first words') + }) + + it('keeps a chat whose history cannot be opened, and its read says why (T4)', async () => { + await rig.chat('session-bad', { message: 'lost' }) + await rig.chat('session-good', { message: 'kept' }) + await rig.crash() + const host = await rig.boot() + rig.historyFilePath.mockImplementation(async (sessionId) => { + if (sessionId === 'session-bad') { + throw new Error('EACCES: permission denied') + } + return null + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const ids = ['session-bad', 'session-good'] + + await host.restoreReadableSessions(ids).catch(() => undefined) + + expect(host.listSessionTabs(ids)).toEqual([tab('session-bad'), tab('session-good')]) + const read = await host.history({ sessionId: 'session-bad', direction: 'tail' }).then( + () => null, + (error: unknown) => error + ) + expect(isAgentSessionRefusalError(read) && read.refusal.code).toBe( + 'agent_session_journal_unreadable' + ) + }) + + it('lists in the order it is given, once each, whatever order the records are in (T7)', async () => { + await rig.chat('session-c') + await rig.chat('session-a') + await rig.chat('session-b') + await rig.crash() + const host = await rig.boot() + + expect(host.listSessionTabs(['session-b', 'session-c', 'session-a', 'session-b'])).toEqual([ + tab('session-b'), + tab('session-c'), + tab('session-a') + ]) + }) + + it('lists no chat without a record, and none this host cannot serve (T10, regression guard)', async () => { + await rig.chat('session-served', { message: 'here' }) + await rig.chat('session-gated', { workspaceId: 'workspace-gated', message: 'there' }) + await rig.crash() + const host = await rig.boot() + rig.unsupportedWorkspaceIds.add('workspace-gated') + const ids = ['session-served', 'session-gated', 'session-missing'] + + await host.restoreReadableSessions(ids) + + expect(host.listSessionTabs(ids)).toEqual([tab('session-served')]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index e5025551360b..e21663f1377e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -102,7 +102,7 @@ describe('structured session provider restore', () => { await restarted.restoreReadableSessions() - expect(restarted.listSessionTabs()).toEqual([ + expect(restarted.listSessionTabs([HOST_TEST_SESSION])).toEqual([ { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 4c2db496c3cf..7e0f530d179a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -335,9 +335,14 @@ describe('agentSessionRefusalOperationState host oracle', () => { ...current, lease: { ...current.lease, unreconciled: true } })) + // A write runs the lease check first; only one that cannot settle leaves this refusal. + const stuck = vi + .spyOn(reconciling.store, 'reconcileOnRestart') + .mockRejectedValue(new Error('execution_owner_reconciling')) const spec = { method, operationId: operationId() } record( await assertHostAgreement(reconciling, spec, 'execution_owner_reconciling', async () => { + stuck.mockRestore() await setLease(reconciling, (current) => ({ ...current, lease: { ...current.lease, unreconciled: false } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index b4d1380090d5..26207a29e9bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -61,6 +61,25 @@ export async function reconcileLeasesAtStartup( } } +/** The chat's queue, entered by a write only once that chat's lease is checked. Startup does not + * wait for the lease check, so a write can reach a chat whose lease is still the previous run's; + * it waits for that check, or runs it. With none owed it enqueues at once, keeping its place. A + * check that fails gates nothing: the lease stays unreconciled, which admits no writer, and the + * next start checks again. */ +export function serializeAfterLeaseCheck( + store: Pick, + reconcile: (sessionId: string) => Promise, + serialize: (sessionId: string, task: () => Promise) => Promise +): (sessionId: string, task: () => Promise) => Promise { + return (sessionId, task) => + store.getRecord(sessionId)?.lease.unreconciled + ? reconcile(sessionId).then( + () => serialize(sessionId, task), + () => serialize(sessionId, task) + ) + : serialize(sessionId, task) +} + async function reconcileCurrentLeases(deps: { store: AgentSessionRecordStore probe: (record: AgentSessionRecord) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts new file mode 100644 index 000000000000..b0c033c2f84b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts @@ -0,0 +1,89 @@ +// Status rows now arrive after the tab list, from the post-listing restore pass. Each must end +// exactly where a fresh host lands when the same chat is opened directly by a read (T8, regression +// guard: the pass is main's own open path; this pins that moving it after the list changed nothing). + +import { cp } from 'node:fs/promises' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' +import { + createStartupRig, + latestStatus, + type StartupRig +} from './structured-agent-session-startup-listing-test-rig' + +const rigs: StartupRig[] = [] + +afterEach(async () => { + for (const rig of rigs.splice(0)) { + await rig.dispose() + } + closeTestJournalHostDatabases() + vi.restoreAllMocks() +}) + +const CORPUS = ['session-settled', 'session-quiet', 'session-crashed', 'session-pending'] + +/** The chats a restart finds: two a clean quit settled, one cut off mid-turn by a crash, and one + * whose send the provider only admitted, left pending below a fence that has since moved. */ +async function seedCorpus(rig: StartupRig): Promise { + await rig.chat('session-settled', { message: 'finished work' }) + await rig.chat('session-quiet') + await rig.host.flushAllStreamedEvents() + await rig.boot() + await rig.chat('session-crashed', { message: 'cut off' }) + rig.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + await rig.chat('session-pending', { message: 'only admitted' }) + await rig.crash() + await rig.store.transitionHandoff('session-pending', (record) => ({ + ...record, + lease: { ...record.lease, runtimeFence: record.lease.runtimeFence + 1 } + })) +} + +it('ends every chat where a direct read of it lands, and shows no pre-crash work (T8)', async () => { + const passRig = await createStartupRig() + rigs.push(passRig) + await seedCorpus(passRig) + // One copy of the files for each boot, taken while nothing is writing. + closeTestJournalHostDatabases() + const readRoot = `${passRig.root}-read` + await cp(passRig.root, readRoot, { recursive: true }) + const readRig = await createStartupRig(readRoot) + rigs.push(readRig) + + // Startup's shape: the lease check started, the list answered, then the pass. + const passHost = await passRig.boot() + const passCheck = passHost.reconcileRestartLeases() + await passHost.restoreReadableSessions(CORPUS) + await passCheck + + // The same run, where the window reads each chat instead. + const readHost = readRig.host + const readCheck = readHost.reconcileRestartLeases() + for (const sessionId of CORPUS) { + await readHost.history({ sessionId, direction: 'tail' }) + } + await readCheck + + // `updatedAt` is when the row was projected, a wall-clock read that differs between two boots. + const rows = (rig: StartupRig): Record> => + Object.fromEntries( + CORPUS.flatMap((sessionId) => { + const summary = latestStatus(rig, sessionId) + if (!summary) { + return [] + } + const { updatedAt: _updatedAt, ...row } = summary + return [[sessionId, row]] + }) + ) + await vi.waitFor(() => expect(rows(passRig)).toEqual(rows(readRig))) + for (const sessionId of CORPUS) { + expect(latestStatus(passRig, sessionId)).toBeDefined() + } + const crashedRows = passRig.statusEvents.flatMap((event) => + event.type === 'status' && event.session.sessionId === 'session-crashed' ? [event.session] : [] + ) + expect(crashedRows.map((row) => row.status)).not.toContain('working') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts new file mode 100644 index 000000000000..479f9b4df51b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts @@ -0,0 +1,242 @@ +// Real hosts over one real record store and journal database, booted in turn the way app runs +// follow each other, for the tests of what a restarted host lists and owes before anyone opens a +// chat. Every journal open calls the adapter's `historyFilePath` once, so that is the open counter, +// and holding it holds the open. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionMutationEnvelope, + AgentSessionStatusEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_LOCATION, + HOST_TEST_NOW, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +export const STARTUP_CALLER = { callerKey: 'client-1' } + +export type StartupRig = { + root: string + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + acquire: Mock + dispatch: Mock + /** Called once per journal open, with the session id; replace its implementation to hold one. */ + historyFilePath: Mock<(sessionId: string) => Promise> + probeOwner: Mock<(record: AgentSessionRecord) => Promise> + /** Workspaces this host's adapter does not serve, as a platform gate would. */ + unsupportedWorkspaceIds: Set + statusEvents: AgentSessionStatusEvent[] + /** Creates the chat, lists its tab unless told not to, and sends `message` when one is given, + * waiting for its dispatch. */ + chat: ( + sessionId: string, + options?: { workspaceId?: string; listed?: boolean; message?: string } + ) => Promise + send: (sessionId: string, text: string) => ReturnType + opensOf: (sessionId: string) => number + /** A crash: nothing settles or flushes; the process's timers and handles simply stop. */ + crash: () => Promise + /** A new app run over the same files; the counters and status stream start empty. */ + boot: (deps?: Partial) => Promise + dispose: () => Promise +} + +export function startupSendEnvelope( + sessionId: string, + body: ReturnType +): AgentSessionMutationEnvelope { + return { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } +} + +function startupAttachParams( + sessionId: string, + workspaceId: string, + fence: number | null +): AgentSessionAttachParams { + return hostTestAttachParams(fence, { + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + }, + location: { ...HOST_TEST_LOCATION, workspaceId }, + providerHandle: { kind: 'codex', threadId: `thread-${sessionId}` } + }) +} + +export async function createStartupRig( + root?: string, + deps: Partial = {} +): Promise { + resetHostTestOperationIds() + const stateRoot = root ?? (await mkdtemp(join(tmpdir(), 'orca-startup-listing-'))) + const directory = join(stateRoot, 'store') + const statusEvents: AgentSessionStatusEvent[] = [] + const historyFilePath = vi.fn(async (_sessionId: string): Promise => null) + const probeOwner: StartupRig['probeOwner'] = vi.fn(async () => ({ outcome: 'pid-absent' })) + const unsupportedWorkspaceIds = new Set() + let ordinal = 0 + const acquire: StartupRig['acquire'] = vi.fn(async ({ fence, spawnToken, identity }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${identity.sessionId}-${fence}`, + handle: { provider: 'codex' as const, threadId: `thread-${identity.sessionId}` }, + origin: rig.store.getRecord(identity.sessionId)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: HOST_TEST_NOW + } + })) + const dispatch: StartupRig['dispatch'] = vi.fn(async (input) => { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: `thread-${input.sessionId}`, + turnId: `turn-${ordinal}`, + ordinal + } + } + }) + const open = async (overrides: Partial) => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async ({ commit }) => commit(), + setOption: async () => undefined, + historyFilePath: ({ identity }) => historyFilePath(identity.sessionId), + supportsCreate: (location, agent) => + agent === 'codex' && !unsupportedWorkspaceIds.has(location.workspaceId) + }, + journalDatabase: openTestJournalHostDatabase(stateRoot), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + probeOwner, + now: () => HOST_TEST_NOW, + ...deps, + ...overrides + }) + host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return { store, host } + } + const first = await open({}) + const rig: StartupRig = { + root: stateRoot, + ...first, + acquire, + dispatch, + historyFilePath, + probeOwner, + unsupportedWorkspaceIds, + statusEvents, + chat: async (sessionId, options = {}) => { + const attached = await rig.host.attach( + STARTUP_CALLER, + startupAttachParams( + sessionId, + options.workspaceId ?? 'workspace-1', + rig.store.getRecord(sessionId)?.lease.runtimeFence ?? null + ) + ) + if (!attached.ok) { + throw new Error(`attach refused: ${attached.refusal.code}`) + } + if (options.listed !== false) { + await rig.store.setSessionTabVisibility(sessionId, true) + } + if (options.message === undefined) { + return + } + const dispatched = dispatch.mock.calls.length + const sent = await rig.send(sessionId, options.message) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await vi.waitFor(() => expect(dispatch.mock.calls.length).toBeGreaterThan(dispatched)) + }, + send: (sessionId, text) => { + const body = hostTestMessage(text) + return rig.host.send(STARTUP_CALLER, { body, envelope: startupSendEnvelope(sessionId, body) }) + }, + opensOf: (sessionId) => historyFilePath.mock.calls.filter(([id]) => id === sessionId).length, + crash: async () => { + const { runtimeState, lifetime, sessions } = rig.host.collaboratorsForTests() + await runtimeState.stopLeaseRenewal() + lifetime.dispose() + for (const session of sessions.values()) { + await session.journal.close() + } + sessions.clear() + }, + boot: async (overrides = {}) => { + // What the new run saw, and nothing the previous one did. + historyFilePath.mockClear() + acquire.mockClear() + dispatch.mockClear() + probeOwner.mockClear() + statusEvents.length = 0 + const next = await open(overrides) + rig.store = next.store + rig.host = next.host + return next.host + }, + dispose: async () => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + if (!root) { + await rm(stateRoot, { recursive: true, force: true }) + } + } + } + return rig +} + +/** The newest row the status stream carried for a session. */ +export function latestStatus(rig: StartupRig, sessionId: string) { + for (const event of rig.statusEvents.toReversed()) { + if (event.type === 'status' && event.session.sessionId === sessionId) { + return event.session + } + if (event.type === 'snapshot') { + const found = event.sessions.find((session) => session.sessionId === sessionId) + if (found) { + return found + } + } + } + return undefined +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 586f8c826300..1cc82cc7b28a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -428,7 +428,7 @@ describe('startup', () => { // The record is readable — the tab comes back, history answers — and nothing is running. expect(acquire).not.toHaveBeenCalled() - expect(host.listSessionTabs()).toEqual([ + expect(host.listSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts new file mode 100644 index 000000000000..93899e5c17a9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts @@ -0,0 +1,109 @@ +// Startup no longer waits for the lease check, so a write can reach a chat whose lease the previous +// app run left unadjudicated. Every write waits for that chat's check (or runs it) before it acts, +// so none is admitted against the old lease and none starts a second agent. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' +import { hostTestOperationId } from './structured-agent-session-host-test-data' +import { + createStartupRig, + STARTUP_CALLER, + type StartupRig +} from './structured-agent-session-startup-listing-test-rig' + +let rig: StartupRig +// A failed assertion must not leave teardown waiting on a held probe. +let releaseProbe = (): void => undefined + +beforeEach(async () => { + rig = await createStartupRig() +}) + +afterEach(async () => { + releaseProbe() + await rig.dispose() + closeTestJournalHostDatabases() + vi.restoreAllMocks() +}) + +/** Boots over a crashed run and starts the startup lease check with its owner probe held. */ +async function bootWithSlowLeaseCheck() { + await rig.chat('session-a', { message: 'before the restart' }) + await rig.crash() + const probe = Promise.withResolvers() + const probing = Promise.withResolvers() + releaseProbe = probe.resolve + rig.probeOwner.mockImplementation(async () => { + probing.resolve() + await probe.promise + return { outcome: 'pid-absent' } + }) + const host = await rig.boot() + // What startup does now: start the check, and wait for nothing. + const startupCheck = host.reconcileRestartLeases() + await probing.promise + // Every admission, with the lease it was admitted against. + const admittedUnreconciled: boolean[] = [] + const admit = rig.store.admitMutationOperation + vi.spyOn(rig.store, 'admitMutationOperation').mockImplementation(async (args) => { + const admitted = await admit(args) + admittedUnreconciled.push(admitted?.record.lease.unreconciled ?? true) + return admitted + }) + return { host, probe, startupCheck, admittedUnreconciled } +} + +/** Long enough for an unheld write to open its chat and be admitted. */ +function settleTasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 200)) +} + +describe('a write during a slow startup lease check', () => { + it('lands a send after the chat’s check, and starts one agent (REQUIRED)', async () => { + const { probe, startupCheck, admittedUnreconciled } = await bootWithSlowLeaseCheck() + + let answered = false + const sent = rig.send('session-a', 'after the restart').then((result) => { + answered = true + return result + }) + await settleTasks() + expect(answered).toBe(false) + expect(admittedUnreconciled).toEqual([]) + + probe.resolve() + await expect(sent).resolves.toMatchObject({ ok: true }) + await startupCheck + await vi.waitFor(() => expect(rig.dispatch).toHaveBeenCalledOnce()) + + expect(admittedUnreconciled).toEqual([false]) + expect(rig.acquire).toHaveBeenCalledOnce() + }) + + it('holds a Stop until the chat’s check has run', async () => { + const { host, probe, startupCheck, admittedUnreconciled } = await bootWithSlowLeaseCheck() + + const fields = {} + const stopped = host.cancel(STARTUP_CALLER, { + envelope: { + sessionId: 'session-a', + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: 'session-a', + fields + }) + } + }) + await settleTasks() + expect(admittedUnreconciled).toEqual([]) + + probe.resolve() + await expect(stopped).resolves.toMatchObject({ ok: true }) + await startupCheck + expect(admittedUnreconciled).toEqual([false]) + expect(rig.acquire).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c0d4c596ffae..6ea17700c7a3 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -252,16 +252,27 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR return this.structuredAgentSessionTabRestorePromise } - prepareStructuredAgentSessionStartupRestoration(): Promise { + /** Startup's structured step: the host and PTY inventory, awaited; the lease check, started and + * never awaited. Neither the tab list nor paint needs leases, and every write to a chat whose + * lease is unchecked waits for its own check (`serializeAfterLeaseCheck`). */ + async prepareStructuredAgentSessionStartupRestoration(): Promise { + await this.ensureStructuredAgentSessionInventory() + if (this.hasPersistedStructuredAgentSessionStore()) { + // Never rejects: a failure goes to the host's startup-reconcile report. + void getStructuredAgentSessionHost()?.reconcileRestartLeases() + } + } + + protected ensureStructuredAgentSessionInventory(): Promise { this.structuredAgentSessionStartupRestorePromise ??= - this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { + this.ensureStructuredAgentSessionInventoryOnce().catch((error) => { this.structuredAgentSessionStartupRestorePromise = null throw error }) return this.structuredAgentSessionStartupRestorePromise } - protected async prepareStructuredAgentSessionStartupRestorationOnce(): Promise { + private async ensureStructuredAgentSessionInventoryOnce(): Promise { if (!this.hasPersistedStructuredAgentSessionStore()) { return } @@ -271,7 +282,6 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR this.ensureStructuredAgentSessionHost() ) await this.refreshMobileSessionPtyRecords() - await getStructuredAgentSessionHost()?.reconcileRestartLeases() } protected hasPersistedStructuredAgentSessionStore(): boolean { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 8d29a0d360b9..90a45ed48a9e 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -54,12 +54,11 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu typeof host?.getPersistedVisibleSessionTabIndex === 'function' ? host.getPersistedVisibleSessionTabIndex() : { present: false, sessionIds: [] } - const profileIds = collectSavedStructuredAgentSessionIds( - this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null - ) - await host?.restoreReadableSessions( - persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - ) + const listedIds = persistedVisibleIndex.present + ? persistedVisibleIndex.sessionIds + : collectSavedStructuredAgentSessionIds( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -70,7 +69,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu for (const replacement of host?.conversationReplacements?.() ?? []) { await this.replaceStructuredAgentSessionTab(replacement) } - for (const session of host?.listSessionTabs() ?? []) { + for (const session of host?.listSessionTabs(listedIds) ?? []) { if (session.agent !== 'codex' && session.agent !== 'claude') { continue } @@ -86,6 +85,11 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu notify: false }) } + // After the tabs are out, so no chat's history holds the list or paint; each chat's status row + // arrives as its history opens. One chat's failure leaves the others and the list alone. + void host?.restoreReadableSessions(listedIds).catch((error: unknown) => { + console.warn('[structured-agent-session] restoring chat history after listing failed', error) + }) const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable // No host means no one can say which chats exist; with none on disk, empty is the answer. this.structuredAgentSessionInventoryUnverifiable = diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 1d8105d07f81..351c3068aaf6 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -51,7 +51,7 @@ describe('structured session cold restoration', () => { expect(restoreReadableSessions).not.toHaveBeenCalled() }) - it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { + it('loads records, inventories PTYs, starts the lease check, projects tabs, then restores history, once', async () => { const runtime = new OrcaRuntimeService() const hydrate = vi.fn() const refresh = vi.fn(async () => new Set()) @@ -102,8 +102,9 @@ describe('structured session cold restoration', () => { expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan( restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity ) - expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan( - hydrate.mock.invocationCallOrder[0] ?? Infinity + // Tabs are projected first; history opens after, so no chat's history holds the list. + expect(hydrate.mock.invocationCallOrder[0]).toBeLessThan( + restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity ) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts index 4aca4d0420a0..09ebcdf3e617 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts @@ -19,6 +19,7 @@ import { workerTerminalLeaseIsCurrent } from './worker-terminal-release-lease' import { resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' import { stopStructuredWorkerForRelease } from './structured-worker-release-stop' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' +import { getStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' export { archiveSummary, @@ -95,7 +96,9 @@ async function completeWorkerTerminalReleaseOnce( if (isStructuredWorkerHandle(resource.terminal_handle)) { // Observation and archive capture both read the structured host, and after a restart nothing // has installed it yet — the startup recovery reconciler runs exactly this path. Installing it - // here is what lets the release see the session instead of reporting it unreadable. + // here is what lets the release see the session instead of reporting it unreadable. Startup no + // longer waits for the lease check either, and an unchecked worker lease from the previous run + // reads as live, so the release runs that check first (it never rejects; failures are reported). // // NOT yet handled, and deliberately follow-up: rebinding a restarted runtime to a structured // worker's redrive subscription. Until that exists, a worker that survives a restart has its @@ -107,6 +110,7 @@ async function completeWorkerTerminalReleaseOnce( error ) }) + await getStructuredAgentSessionHost()?.reconcileRestartLeases() } const worker = db.getWorkerDispatch(dispatchId) if (!worker || worker.agent_terminal_handle !== resource.terminal_handle) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts new file mode 100644 index 000000000000..7972b485f7f1 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts @@ -0,0 +1,62 @@ +// After a restart the release is the first thing to read a structured worker, and startup no longer +// waits for the lease check. The release runs that check before it observes the worker (T6). + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { mintStructuredWorkerHandle } from '../../../../structured-worker-identity' + +const { order } = vi.hoisted(() => { + const order: string[] = [] + return { order } +}) + +vi.mock('./worker-observation', () => ({ + inspectWorkerTerminal: vi.fn(async () => { + order.push('observe') + return { status: 'identity_changed' } + }) +})) + +const { completeWorkerTerminalRelease } = await import('./worker-release-completion') + +afterEach(() => { + order.length = 0 + setStructuredAgentSessionHost(null) +}) + +describe('structured worker release after a restart', () => { + it('reads the worker only once the restart reconcile has settled its lease', async () => { + const handle = mintStructuredWorkerHandle() + const runtime = { + ensureStructuredAgentSessionHost: vi.fn(async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release reads only the reconcile stubbed here. + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => { + // A reconcile that probes processes settles after a macrotask, not a microtask. + await new Promise((resolve) => setTimeout(resolve, 0)) + order.push('reconcile') + } + } as never) + }) + } + const db = { + getWorkerDispatch: () => ({ agent_terminal_handle: handle }), + revertWorkerTerminalReleaseToRetained: () => ({ archive_source: null, archive_status: null }), + recordWorkerTerminalRecoveryAttempt: vi.fn() + } + + const receipt = await completeWorkerTerminalRelease({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this path reads only the runtime member stubbed above. + runtime: runtime as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retained path reads only the dispatch rows stubbed above. + db: db as never, + dispatchId: 'dispatch-1', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retained path reads only the id and handle. + resource: { id: 'resource-1', terminal_handle: handle } as never, + mode: 'recovery' + }) + + expect(receipt.state).toBe('retained') + expect(order).toEqual(['reconcile', 'observe']) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts index b48dccf52a39..0945d32834f0 100644 --- a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -43,9 +43,11 @@ function installHost(options: { closeThrows?: boolean; lease?: Record undefined, hasSession: () => attached, hold: async () => {}, release: () => {}, diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 4b444eef70e1..d8f85a11ee66 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -83,7 +83,8 @@ it('finishes startup over records a newer Orca wrote, reports it, and writes not // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - expect(onError).toHaveBeenCalledOnce() + // Startup does not wait for the check; its failure is reported when it lands. + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) expect(onError).toHaveBeenCalledWith({ scope: 'structured-agent-session-startup-reconcile', error: expect.objectContaining({ message: 'agent_session_legacy_required' }) @@ -103,8 +104,10 @@ it('logs the failure when the host has no error sink', async () => { startupRuntime().prepareStructuredAgentSessionStartupRestoration() ).resolves.toBeUndefined() - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] reconciling chat leases at startup failed', - expect.objectContaining({ message: 'agent_session_legacy_required' }) + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] reconciling chat leases at startup failed', + expect.objectContaining({ message: 'agent_session_legacy_required' }) + ) ) }) diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts new file mode 100644 index 000000000000..669732c321e6 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -0,0 +1,245 @@ +// The chat tab list a client asks for at startup answers from records and the tab table. It waits +// for no chat's history and for no lease check; each chat's history opens after the answer. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createStartupRig, + latestStatus, + type StartupRig +} from '../native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig' +import { OrcaRuntimeService } from './orca-runtime' +import { RpcDispatcher } from './rpc/dispatcher' +import { SESSION_TAB_METHODS } from './rpc/methods/session-tabs' + +type ListingInternals = { + store: { getWorkspaceSession: () => unknown } + getClientSettings(): { experimentalStructuredNativeChat: boolean } + supportsAuthoritativeSessionTabsInventory(): boolean + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise +} + +const CONTEXT = { + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +let rig: StartupRig +// Anything a test holds is let go before teardown, so a failed assertion cannot hang it. +let releaseHeld = (): void => undefined + +beforeEach(async () => { + rig = await createStartupRig() +}) + +afterEach(async () => { + releaseHeld() + setStructuredAgentSessionHost(null) + await rig.dispose() + closeTestJournalHostDatabases() + vi.restoreAllMocks() +}) + +/** A restarted runtime whose structured host is the rig's current one. */ +function restartedRuntime(workspaceSession: unknown = null) { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as ListingInternals + internal.store = { getWorkspaceSession: () => workspaceSession } + internal.getClientSettings = () => ({ experimentalStructuredNativeChat: true }) + // The PTY census behind an authoritative inventory is not what this pins. + internal.supportsAuthoritativeSessionTabsInventory = () => false + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => { + setStructuredAgentSessionHost(rig.host) + } + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const listAll = async (): Promise => { + const response = await dispatcher.dispatch( + { id: 'list-all', authToken: 'tok', method: 'session.tabs.listAll' }, + CONTEXT + ) + if (!response.ok) { + throw new Error(`listAll refused: ${JSON.stringify(response.error)}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: listAll answers with the inventory shape its handler returns. + const { snapshots } = response.result as { snapshots: RuntimeMobileSessionTabsResult[] } + return snapshots.flatMap((snapshot) => + snapshot.tabs.flatMap((tab) => (tab.type === 'agent-session' ? [tab.sessionId] : [])) + ) + } + return { runtime, listAll } +} + +/** Resolves with the answer, or with 'still waiting' once `ms` pass without one. */ +function within(promise: Promise, ms = 2_000): Promise { + return Promise.race([ + promise, + new Promise<'still waiting'>((resolve) => setTimeout(() => resolve('still waiting'), ms)) + ]) +} + +describe('listing chat tabs at startup', () => { + it('answers before any chat opens, then opens each one (T1)', async () => { + const ids = ['session-1', 'session-2', 'session-3'] + for (const sessionId of ids) { + await rig.chat(sessionId, { message: sessionId }) + } + await rig.crash() + await rig.boot() + const opens = Promise.withResolvers() + releaseHeld = opens.resolve + let opened = 0 + rig.historyFilePath.mockImplementation(async () => { + await opens.promise + opened += 1 + return null + }) + const { listAll } = restartedRuntime() + + expect(await within(listAll())).toEqual(ids) + expect(opened).toBe(0) + + opens.resolve() + await vi.waitFor(() => ids.forEach((id) => expect(rig.host.hasSession(id)).toBe(true))) + expect(opened).toBe(3) + }) + + it('answers while the startup lease check never finishes (T2)', async () => { + await rig.chat('session-1', { message: 'one' }) + await rig.chat('session-2', { message: 'two' }) + await rig.crash() + const probe = Promise.withResolvers() + releaseHeld = probe.resolve + rig.probeOwner.mockImplementation(async () => { + await probe.promise + return { outcome: 'pid-absent' } + }) + await rig.boot() + const { runtime, listAll } = restartedRuntime() + + // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. + expect(await within(runtime.prepareStructuredAgentSessionStartupRestoration())).toBe(undefined) + expect(await within(listAll())).toEqual(['session-1', 'session-2']) + expect(rig.probeOwner).toHaveBeenCalled() + probe.resolve() + await rig.host.reconcileRestartLeases() + }) + + it('answers when the startup lease check fails (T2)', async () => { + await rig.chat('session-1', { message: 'one' }) + await rig.crash() + rig.probeOwner.mockRejectedValue(new Error('probe unavailable')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const onStartupReconcileFailure = vi.fn() + await rig.boot({ onStartupReconcileFailure }) + const { runtime, listAll } = restartedRuntime() + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBe(undefined) + expect(await within(listAll())).toEqual(['session-1']) + await vi.waitFor(() => + expect(onStartupReconcileFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: 'probe unavailable' }) + ) + ) + }) + + it('lists every chat when one fails to open, and the others still get status rows (T5)', async () => { + const ids = ['session-1', 'session-2', 'session-3'] + for (const sessionId of ids) { + await rig.chat(sessionId, { message: `asked ${sessionId}` }) + } + await rig.crash() + await rig.boot() + rig.historyFilePath.mockImplementation(async (sessionId) => { + if (sessionId === 'session-2') { + throw new Error('EACCES: permission denied') + } + return null + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const restore = vi.spyOn(rig.host, 'restoreReadableSessions') + const { listAll } = restartedRuntime() + + expect(await within(listAll())).toEqual(ids) + await vi.waitFor(() => { + expect(latestStatus(rig, 'session-1')).toMatchObject({ latestPrompt: 'asked session-1' }) + expect(latestStatus(rig, 'session-3')).toMatchObject({ latestPrompt: 'asked session-3' }) + }) + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] restoring chat history after listing failed', + expect.objectContaining({ message: 'EACCES: permission denied' }) + ) + ) + expect(await listAll()).toEqual(ids) + expect(restore).toHaveBeenCalledOnce() + expect( + warn.mock.calls.filter(([message]) => String(message).includes('after listing')) + ).toHaveLength(1) + }) + + it('lists in tab-table order, not the order the chats were created (T7)', async () => { + for (const sessionId of ['session-c', 'session-a', 'session-b']) { + await rig.chat(sessionId, { listed: false }) + } + for (const sessionId of ['session-b', 'session-c', 'session-a']) { + await rig.store.setSessionTabVisibility(sessionId, true) + } + await rig.crash() + await rig.boot() + const { listAll } = restartedRuntime() + + expect(await within(listAll())).toEqual(['session-b', 'session-c', 'session-a']) + }) + + it('lists in the saved window order when the profile has no tab table (T7)', async () => { + for (const sessionId of ['session-c', 'session-a', 'session-b']) { + await rig.chat(sessionId, { listed: false }) + } + await rig.crash() + const host = await rig.boot() + vi.spyOn(host, 'getPersistedVisibleSessionTabIndex').mockReturnValue({ + present: false, + sessionIds: [] + }) + const savedTab = (sessionId: string, sortOrder: number) => ({ + id: `agent-session:${sessionId}`, + entityId: sessionId, + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: 1 + }) + const { listAll } = restartedRuntime({ + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + 'workspace-1': [ + savedTab('session-a', 0), + savedTab('session-b', 1), + savedTab('session-c', 2) + ] + } + }) + + expect(await within(listAll())).toEqual(['session-a', 'session-b', 'session-c']) + }) +}) From b23eb280daa9317226941d24a883caeb895bf28b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:01:18 -0700 Subject: [PATCH 04/21] fix(native-chat): keep every record-store write off the startup chat read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes. --- .../structured-agent-session-host-types.ts | 5 +- .../structured-agent-session-host.ts | 1 + ...ed-agent-session-readable-restorer.test.ts | 2 +- ...uctured-agent-session-restart-reconcile.ts | 39 +- ...ured-agent-session-restart-restore.test.ts | 7 +- ...tructured-agent-session-restart-restore.ts | 26 +- ...ent-session-restore-without-import.test.ts | 2 +- .../structured-agent-session-reveal.ts | 28 +- ...-session-startup-reconcile-failure.test.ts | 35 +- .../runtime/agent-session-record-store.ts | 8 +- src/main/runtime/agent-session-tab-table.ts | 12 + ...tore-structured-agent-session-tabs-once.ts | 65 ++-- ...runtime-structured-session-restore.test.ts | 31 +- .../rpc/methods/structured-agent-session.ts | 2 +- ...ed-agent-session-startup-reconcile.test.ts | 156 +------- ...-agent-session-startup-tab-restore.test.ts | 351 ++++++++++++++++++ ...structured-agent-session-tab-index-seed.ts | 35 ++ 17 files changed, 593 insertions(+), 212 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-startup-tab-restore.test.ts create mode 100644 src/main/runtime/structured-agent-session-tab-index-seed.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index e2e8e07d4fb3..f69bc3fb06aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -120,8 +120,9 @@ export type StructuredAgentSessionHostDeps = { /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - /** A lease reconcile run for startup or a read that refused or threw, once per distinct failure. - * Startup and the read carry on: the next attach or send reconciles every lease again. */ + /** Lease bookkeeping run for startup or a read (the reconcile, or resolving a chat's recovery) + * that refused or threw, once per distinct failure. Startup and the read carry on: the next + * attach or send reconciles and resolves recovery again before it acts. */ onLeaseReconcileFailure?: (failure: unknown) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 78bc7de155ef..406f105a7c75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -215,6 +215,7 @@ export class StructuredAgentSessionHost { listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) + showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds) setSessionTabVisibility = async ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 240882f17564..09cab618ab9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -40,7 +40,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { }, supportsRecord: () => true, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index c9cba45aa59d..be2d0b6c1513 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,32 +45,51 @@ export function createRestartReconciler(deps: { } } +/** Reports each distinct failure once, until `clear` says the bookkeeping settled again. Every read + * re-runs a failing reconcile, so without this a failing store would log on every chat list. */ +export type ReaderBookkeepingFailures = { + report: (failure: unknown) => void + clear: () => void +} + +export function reportEachFailureOnce( + onFailure: ((failure: unknown) => void) | undefined +): ReaderBookkeepingFailures { + let reported: string | null = null + return { + report: (failure) => { + const key = failureKey(failure) + if (key !== reported) { + reported = key + onFailure?.(failure) + } + }, + clear: () => { + reported = null + } + } +} + /** The reconcile a reader runs, at startup and before each restored read: it never throws, since * an unreconciled lease grants no writer and the next send reconciles again before it acts. - * Answers whether every lease is settled; a failure is reported once until a reconcile settles. */ + * Answers whether every lease is settled. */ export function createReaderReconcile( reconcile: (sessionId: string) => Promise, - onFailure: ((failure: unknown) => void) | undefined + failures: ReaderBookkeepingFailures ): (sessionId: string) => Promise { - let reported: string | null = null return async (sessionId) => { let failure: unknown try { const refusal = await reconcile(sessionId) if (!refusal) { - reported = null + failures.clear() return true } failure = refusal } catch (error) { failure = error } - // Every read re-runs a failing reconcile, so each distinct failure is reported once. - const key = failureKey(failure) - if (key !== reported) { - reported = key - onFailure?.(failure) - } + failures.report(failure) return false } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 3bf000f9e409..c7d6b21ea1b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -63,7 +63,7 @@ describe('restart journal restoration', () => { openDeps: NO_OPEN_DEPS, records, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined @@ -104,7 +104,7 @@ describe('restart journal restoration', () => { openDeps: NO_OPEN_DEPS, records, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined @@ -153,6 +153,7 @@ describe('restart journal restoration', () => { reconcile: async () => true, resolveRecovery: async () => { calls.push('resolveRecovery') + return true }, serialize: async (_sessionId, task) => task(), hasSession: () => false, @@ -175,7 +176,7 @@ describe('restart journal restoration', () => { records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => true, onReadable: () => undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 02561594caa8..facc67a86a92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -27,9 +27,11 @@ export type StructuredAgentSessionReadRestoreDeps = { openDeps: StructuredAgentSessionConversationOpenDeps & { store: Pick } - /** Whether every lease is settled. Never throws: a read grants no writer, so bookkeeping can't block it. */ + // Lease bookkeeping. Neither throws: a read grants no writer, so bookkeeping must not block it. + /** Whether every lease is settled. */ reconcile: (sessionId: string) => Promise - resolveRecovery: (sessionId: string) => Promise + /** False when its store write failed; the next attach or send resolves it again. */ + resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: ( @@ -41,12 +43,10 @@ export type StructuredAgentSessionReadRestoreDeps = { /** One session's share of the restart restore. Startup maps this over every supported record. */ async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, - sessionId: string + sessionId: string, + settleLeases: (sessionId: string) => Promise ): Promise { - if (await input.reconcile(sessionId)) { - // A session latched in recovery exits here at startup, without waiting for a client. - await input.resolveRecovery(sessionId) - } + await settleLeases(sessionId) await input.serialize(sessionId, () => restoreOneStructuredAgentSessionReadUnderSerialize(input, sessionId) ) @@ -72,9 +72,19 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { + // Once bookkeeping fails in this pass, trying again per chat only waits on the same store again. + let bookkeepingFailed = false + const settleLeases = async (sessionId: string): Promise => { + if (bookkeepingFailed) { + return + } + // A session latched in recovery exits here at startup, without waiting for a client. + const settled = (await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId)) + bookkeepingFailed ||= !settled + } await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. await yieldToEventLoop() - await restoreOneStructuredAgentSessionRead(input, sessionId) + await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 1308dbfe4a83..3290ba05f0de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -233,7 +233,7 @@ async function restore(sessionIds: readonly string[]) { openDeps: deps, records: sessionIds.map(recordFor), reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: (sessionId) => sessions.has(sessionId), onReadable: (sessionId, opened) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 96b0abd2b113..262c9e18d3c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -15,7 +15,10 @@ import { agentSessionRefusalError } from '../../../shared/agent-session-wire-ref import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' -import { createReaderReconcile } from './structured-agent-session-restart-reconcile' +import { + createReaderReconcile, + reportEachFailureOnce +} from './structured-agent-session-restart-reconcile' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal @@ -54,23 +57,36 @@ export async function revealStructuredAgentSession( } /** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. - * Its reconcile is a reader's, which never fails a read or startup; startup runs the same one. */ + * Its lease bookkeeping is a reader's, which never fails a read or startup; startup shares it. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' | 'reconcile' - > & { reconcileLeases: (sessionId: string) => Promise } + 'openDeps' | 'supportsRecord' | 'reconcile' | 'resolveRecovery' + > & { + reconcileLeases: (sessionId: string) => Promise + resolveRecovery: (sessionId: string) => Promise + } ): { reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { - const { reconcileLeases, ...rest } = wiring - const reconcile = createReaderReconcile(reconcileLeases, deps.onLeaseReconcileFailure) + const { reconcileLeases, resolveRecovery, ...rest } = wiring + const failures = reportEachFailureOnce(deps.onLeaseReconcileFailure) + const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), reconcile, + // The next attach or send resolves recovery again, strictly, before it acts. + resolveRecovery: (sessionId) => + resolveRecovery(sessionId).then( + () => true, + (error: unknown) => { + failures.report(error) + return false + } + ), ...rest }) const gate = new StructuredAgentSessionRestartRestoreGate() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 5e1c8a95868c..8033843cd41e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -11,7 +11,10 @@ import { AGENT_SESSION_STORE_SCHEMA_VERSION, agentSessionStorePath } from '../../runtime/agent-session-record-store-file' -import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + StructuredAgentSessionHost, + type StructuredAgentSessionHostDeps +} from './structured-agent-session-host' import { adapter, attach, @@ -53,7 +56,12 @@ afterEach(async () => { }) /** A host with one chat, relaunched over a copy of its files; `rewrite` edits the copied store. */ -async function relaunch(rewrite?: (json: Record) => void) { +async function relaunch( + rewrite?: (json: Record) => void, + probeOwner: StructuredAgentSessionHostDeps['probeOwner'] = async () => ({ + outcome: 'pid-absent' + }) +) { const dying = hostTestState() await attach() // An empty renewal queues behind every record write, so they are on disk. @@ -80,7 +88,10 @@ async function relaunch(rewrite?: (json: Record) => void) { journalDatabase: openTestJournalHostDatabase(relaunched), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner, + stopOwnerProcess: () => { + throw new Error('a read must not stop an owner') + }, now: () => NOW, onLeaseReconcileFailure }) @@ -168,3 +179,21 @@ it('restores a chat for reading from a store a newer Orca wrote', async () => { expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() expect(await readFile(path)).toEqual(bytes) }) + +// A chat whose owner could not be proven gone is left recovering; resolving that is the writer's. +it('restores a chat for reading when resolving its recovery cannot write the store', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch(undefined, async () => ({ + outcome: 'indeterminate', + reason: 'probe' + })) + await host.reconcileRestartLeases() + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + lock.failing = true + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 925fa38d8d18..603b5ed02faf 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -66,7 +66,7 @@ import { agentSessionStorePath, type AgentSessionStoreState } from './agent-session-record-store-file' -import { setAgentSessionTabVisibility } from './agent-session-tab-table' +import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' import { AgentSessionStoreTransactionQueue, @@ -146,6 +146,12 @@ export class AgentSessionRecordStore { return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible, tabId)) } + /** Shows each session that still has a record, in one write: an index written part way would + * read as complete at the next launch and drop the rest. */ + showSessionTabs(sessionIds: readonly string[]): Promise { + return this.transact(() => showAgentSessionTabs(this.state, sessionIds)) + } + listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { const scope = agentSessionScopeKey(location) return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope) diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 6022aac8bf2b..840356cf0cf0 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -131,6 +131,18 @@ export function setAgentSessionTabVisibility( } } +/** Shows each session that still has a record. */ +export function showAgentSessionTabs( + state: AgentSessionStoreState, + sessionIds: readonly string[] +): void { + for (const sessionId of sessionIds) { + if (state.records.has(sessionId)) { + setAgentSessionTabVisibility(state, sessionId, true) + } + } +} + export type PersistedAgentSessionTab = { tabId: string; sessionId: string } export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 476b07820552..1eea896b5dce 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -5,6 +5,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' +import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionAgentTab, @@ -28,7 +29,9 @@ import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import type { PtyProcessInspection } from '../providers/pty-process-inspection' export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { - async replaceStructuredAgentSessionTab(replacement: ConversationReplacement): Promise { + /** Projects only: a replacement's chat already has its tab in the store, which the /clear commit + * moved in the same write. */ + replaceStructuredAgentSessionTab(replacement: ConversationReplacement): void { const prior = this.mobileSessionTabsByWorktree.get(replacement.workspaceId) const next = prior ? replaceConversationInSnapshot(prior, replacement) : null if (next && next !== prior) { @@ -39,7 +42,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu (tab) => tab.type === 'agent-session' && tab.sessionId === replacement.sessionId ) ) { - await this.publishStructuredAgentSessionTab({ + this.projectStructuredAgentSessionTab({ ...replacement, replacesSessionId: replacement.sourceSessionId, activate: false @@ -57,9 +60,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - await host?.restoreReadableSessions( - persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - ) + const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds + await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -67,25 +69,27 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - for (const replacement of host?.conversationReplacements?.() ?? []) { - await this.replaceStructuredAgentSessionTab(replacement) - } - for (const session of host?.listSessionTabs() ?? []) { + const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { if (session.agent !== 'codex' && session.agent !== 'claude') { - continue + return [] } let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) } - await this.publishStructuredAgentSessionTab({ - ...session, - agent: session.agent, - sessionId, - activate: false, - notify: false, - visibilityWriteMayFail: true - }) + return [{ ...session, agent: session.agent, sessionId }] + }) + await seedStructuredAgentSessionTabIndex( + host, + targets, + restored.map((session) => session.sessionId) + ) + // The store already lists every tab projected here, so the restore writes nothing else. + for (const replacement of host?.conversationReplacements?.() ?? []) { + this.replaceStructuredAgentSessionTab(replacement) + } + for (const session of restored) { + this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable // No host means no one can say which chats exist; with none on disk, empty is the answer. @@ -106,26 +110,27 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu replacesSessionId?: string /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string - /** A restore's republish: a failed visibility write is logged and the tab still publishes, - * since a client drops every unpublished chat tab. */ - visibilityWriteMayFail?: boolean }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - const written = host.setSessionTabVisibility( + await host.setSessionTabVisibility( input.sessionId, true, ...(input.tabId ? [input.tabId] : []) ) - await (input.visibilityWriteMayFail - ? written.catch((error) => - console.warn('[structured-agent-session] recording a restored chat tab failed', { - sessionId: input.sessionId, - error - }) - ) - : written) } + this.projectStructuredAgentSessionTab(input) + } + + /** The runtime's own snapshot of a chat tab. Records nothing: the caller owns the store write. */ + projectStructuredAgentSessionTab(input: { + workspaceId: string + sessionId: string + agent: 'claude' | 'codex' + activate: boolean + notify?: boolean + replacesSessionId?: string + }): void { const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` if (existing?.tabs.some((tab) => tab.id === id)) { diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 80b8e4f3be65..929ee82b6c89 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -324,7 +324,7 @@ describe('structured session cold restoration', () => { it('publishes restored Claude tabs with the Claude title', async () => { const runtime = new OrcaRuntimeService() - const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const project = vi.spyOn(runtime, 'projectStructuredAgentSessionTab') const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean getKnownWorkspaceSessionWorktreeIds(): Set @@ -351,13 +351,12 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(publish).toHaveBeenCalledWith({ + expect(project).toHaveBeenCalledWith({ workspaceId: 'workspace-1', sessionId: 'restored-claude', agent: 'claude', activate: false, - notify: false, - visibilityWriteMayFail: true + notify: false }) const restored = await runtime.listMobileSessionTabs('id:workspace-1') @@ -373,6 +372,30 @@ describe('structured session cold restoration', () => { ) }) + // The /clear commit moved the tab in the store already, so replacing it only projects. + it('replaces a cleared conversation tab without a store write', async () => { + const runtime = new OrcaRuntimeService() + const setSessionTabVisibility = vi.fn(async () => undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replacing a tab reaches the host only through setSessionTabVisibility, which must stay uncalled. + setStructuredAgentSessionHost({ setSessionTabVisibility } as never) + + runtime.replaceStructuredAgentSessionTab({ + sourceSessionId: 'cleared-session', + sessionId: 'replacement-session', + workspaceId: 'workspace-1', + agent: 'codex' + }) + + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs).toEqual([ + expect.objectContaining({ + id: 'agent-session:replacement-session', + replacesSessionId: 'cleared-session' + }) + ]) + expect(setSessionTabVisibility).not.toHaveBeenCalled() + }) + it('commits the host close when the renderer already removed the structured tab', async () => { const runtime = new OrcaRuntimeService() runtime.setNotifier({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 534d49677793..463772d33375 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -123,7 +123,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ .conversationReplacements() .find((entry) => entry.sourceSessionId === params.envelope.sessionId) if (replacement) { - await ctx.runtime.replaceStructuredAgentSessionTab(replacement) + ctx.runtime.replaceStructuredAgentSessionTab(replacement) } await host.close(params.envelope.sessionId) } diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 8fecec36a2ce..33b26f7569bb 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -5,15 +5,8 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type * as FileTransactionLock from '../file-transaction-lock' -import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' -import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -25,22 +18,6 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -const lock = vi.hoisted(() => ({ failing: false })) - -vi.mock('../file-transaction-lock', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - withFileTransactionLock: (...args: Parameters) => - lock.failing - ? // What proper-lockfile throws once its retries give up. - Promise.reject( - Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) - ) - : actual.withFileTransactionLock(...args) - } -}) - let root: string beforeEach(async () => { @@ -48,16 +25,13 @@ beforeEach(async () => { }) afterEach(async () => { - lock.failing = false await stopStructuredAgentSessionRuntime().catch(() => undefined) vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) -const PROMPT = 'add a retry' - -/** A profile's record store with one chat in it; one a newer Orca wrote opens read-only here. */ -async function seedStore(options: { newer: boolean; visible?: boolean }) { +/** A profile whose record store a newer Orca wrote, with one chat in it. */ +async function seedNewerStore() { const storeDirectory = join(root, 'agent-sessions') const path = agentSessionStorePath(storeDirectory) const record = agentSessionRecordFixture() @@ -65,61 +39,28 @@ async function seedStore(options: { newer: boolean; visible?: boolean }) { await writeFile( path, JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, hostId: 'local', records: { [record.sessionId]: record }, operations: {}, retiredClaimKeys: [], - unusableRecords: {}, - ...(options.visible ? { visibleSessionIds: [record.sessionId] } : {}) + unusableRecords: {} }) ) - return { storeDirectory, path, record, sessionId: record.sessionId } + return { storeDirectory, path, sessionId: record.sessionId } } -/** The chat's history as the last run left it: one prompt, accepted. */ -async function seedHistory(record: AgentSessionRecord): Promise { - const fence = record.lease.runtimeFence - const database = JournalHostDatabase.open(root) - const journal = await openAgentSessionJournal({ - identity: journalIdentityFor( - record, - attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: fence }) - ), - database - }) - await journal.appendSubmission({ - clientMessageId: 'client-1', - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, - fence, - handoverRecorded: true - }) - await journal.resolveDispatch({ - clientMessageId: 'client-1', - fence, - state: 'accepted', - providerIdentity: null - }) - await journal.close() - database.close() -} - -function startupRuntime( - onError?: (input: { scope: string; error: unknown }) => void, - afterInstall?: () => void -) { +function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean ensureStructuredAgentSessionHost(): Promise refreshMobileSessionPtyRecords(): Promise | null> - mobileSessionTabsByWorktree: Map } internal.hasPersistedStructuredAgentSessionStore = () => true - internal.ensureStructuredAgentSessionHost = async () => { - const installed = await ensureStructuredAgentSessionHost({ + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -128,19 +69,16 @@ function startupRuntime( resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), ...(onError ? { onError } : {}) }) - afterInstall?.() - return installed - } internal.refreshMobileSessionPtyRecords = async () => new Set() - return { runtime, publishedTabs: () => internal.mobileSessionTabsByWorktree.get('workspace-1') } + return runtime } it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { - const { storeDirectory, path, sessionId } = await seedStore({ newer: true }) + const { storeDirectory, path, sessionId } = await seedNewerStore() const bytes = await readFile(path) const files = await readdir(storeDirectory) const onError = vi.fn() - const { runtime } = startupRuntime(onError) + const runtime = startupRuntime(onError) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() @@ -158,11 +96,11 @@ it('finishes startup over records a newer Orca wrote, reports it, and writes not // The desktop installs its host with no error sink, so the failure is logged rather than dropped. it('logs the failure when the host has no error sink', async () => { - await seedStore({ newer: true }) + await seedNewerStore() const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect( - startupRuntime().runtime.prepareStructuredAgentSessionStartupRestoration() + startupRuntime().prepareStructuredAgentSessionStartupRestoration() ).resolves.toBeUndefined() expect(warn).toHaveBeenCalledWith( @@ -170,69 +108,3 @@ it('logs the failure when the host has no error sink', async () => { expect.objectContaining({ message: 'agent_session_legacy_required' }) ) }) - -// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), -// which reads every chat whose tab was open at quit. That read must not wait on the lease bookkeeping. -describe('restoring the chat tabs open at quit', () => { - async function expectChatRestored( - runtime: OrcaRuntimeService, - publishedTabs: () => RuntimeMobileSessionTabsSnapshot | undefined, - sessionId: string - ) { - await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() - expect(publishedTabs()?.tabs.map((tab) => tab.id)).toEqual([`agent-session:${sessionId}`]) - const host = getStructuredAgentSessionHost() - expect(JSON.stringify((await host!.journalSnapshot(sessionId)).items)).toContain(PROMPT) - } - - /** The reconcile failure once, though startup and the read both ran it; the tab write per chat. */ - function expectReported( - onError: ReturnType, - warn: ReturnType, - sessionId: string, - failure: Record - ) { - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining(failure) - }) - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording a restored chat tab failed', - { sessionId, error: expect.objectContaining(failure) } - ) - } - - it('lists and reads a chat from records a newer Orca wrote, and writes nothing', async () => { - const { storeDirectory, path, record } = await seedStore({ newer: true, visible: true }) - await seedHistory(record) - const bytes = await readFile(path) - const files = await readdir(storeDirectory) - const onError = vi.fn() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, publishedTabs } = startupRuntime(onError) - - await expectChatRestored(runtime, publishedTabs, record.sessionId) - - expectReported(onError, warn, record.sessionId, { message: 'agent_session_legacy_required' }) - await stopStructuredAgentSessionRuntime() - expect(await readFile(path)).toEqual(bytes) - expect(await readdir(storeDirectory)).toEqual(files) - }) - - it('lists and reads a chat while the record store lock keeps failing', async () => { - const { record } = await seedStore({ newer: false, visible: true }) - await seedHistory(record) - const onError = vi.fn() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - // Free when the store opens, then held for good: every later write gives up. - const { runtime, publishedTabs } = startupRuntime(onError, () => { - lock.failing = true - }) - - await expectChatRestored(runtime, publishedTabs, record.sessionId) - - expectReported(onError, warn, record.sessionId, { code: 'ELOCKED' }) - lock.failing = false - }) -}) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts new file mode 100644 index 000000000000..77797a5f531b --- /dev/null +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -0,0 +1,351 @@ +// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), +// which reads every chat whose tab was open at quit. That read must not wait on record-store +// bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs +// a bounded number of lock waits, not one per chat. + +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type * as FileTransactionLock from '../file-transaction-lock' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from './agent-session-record-store-file' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +// `failing` refuses every take; `grants` lets that many more through, then refuses. +const lock = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) + +vi.mock('../file-transaction-lock', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + withFileTransactionLock: (...args: Parameters) => { + if (lock.failing || lock.grants <= 0) { + lock.refused += 1 + // What proper-lockfile throws once its retries give up, about 3 s later. + return Promise.reject( + Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) + ) + } + lock.grants -= 1 + return actual.withFileTransactionLock(...args) + } + } +}) + +const PROMPT = 'add a retry' +const CHAT_A = 'chat-a-0001' +const CHAT_B = 'chat-b-0002' +const CLEARED = 'chat-s-0003' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-startup-tab-restore-')) +}) + +afterEach(async () => { + Object.assign(lock, { failing: false, grants: Infinity, refused: 0 }) + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** A released chat, so no startup probe looks for a live owner. */ +function chatRecord( + sessionId: string, + options: { codex?: boolean; clearedInto?: string } = {} +): AgentSessionRecord { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + ) + const codex = options.codex + ? { + provider: 'codex' as const, + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { provider: 'codex' as const, threadId: `thread-${sessionId}` } + })), + accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') } + } + : {} + const clear = options.clearedInto + ? { + conversationCommand: { + command: 'clear' as const, + state: 'completed' as const, + phase: 'committed' as const, + operationId: `clear-${sessionId}`, + callerKey: 'client-1', + replacementSessionId: options.clearedInto + } + } + : {} + return { ...record, ...codex, ...clear } +} + +/** Writes the record store; `visible` is the saved tab index, absent on a legacy profile. */ +async function seedStore( + records: AgentSessionRecord[], + options: { newer?: boolean; visible?: string[] } = {} +) { + const storeDirectory = join(root, 'agent-sessions') + const path = agentSessionStorePath(storeDirectory) + await mkdir(storeDirectory, { recursive: true }) + await writeFile( + path, + JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), + hostId: 'local', + records: Object.fromEntries(records.map((record) => [record.sessionId, record])), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {}, + ...(options.visible ? { visibleSessionIds: options.visible } : {}) + }) + ) + return { storeDirectory, path } +} + +/** Each chat's history as the last run left it: one prompt, accepted, so nothing is left to send. */ +async function seedHistory(records: AgentSessionRecord[]): Promise { + const database = JournalHostDatabase.open(root) + for (const record of records) { + const fence = record.lease.runtimeFence + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: fence }) + ), + database + }) + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence, + handoverRecorded: true + }) + await journal.resolveDispatch({ + clientMessageId: 'client-1', + fence, + state: 'accepted', + providerIdentity: null + }) + await journal.close() + } + database.close() +} + +function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { + const onError = vi.fn() + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root`, stubs the PTY daemon and gives it a profile. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + mobileSessionTabsByWorktree: Map + store: { getWorkspaceSession: () => unknown } + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = async () => { + const installed = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + onError + }) + options.afterInstall?.() + return installed + } + internal.refreshMobileSessionPtyRecords = async () => new Set() + // A legacy profile's saved chat tabs, which a store with no tab index restores from. + internal.store = { + getWorkspaceSession: () => ({ + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + 'workspace-1': (options.profileChats ?? []).map((sessionId, index) => ({ + id: `agent-session:${sessionId}`, + entityId: sessionId, + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: index, + createdAt: 1 + })) + } + }) + } + return { + runtime, + onError, + published: () => internal.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] + } +} + +async function expectHistory(sessionId: string): Promise { + const host = getStructuredAgentSessionHost() + expect(JSON.stringify((await host!.journalSnapshot(sessionId)).items)).toContain(PROMPT) +} + +function spyOnTabWrites() { + return { + visibility: vi.spyOn(AgentSessionRecordStore.prototype, 'setSessionTabVisibility'), + seed: vi.spyOn(AgentSessionRecordStore.prototype, 'showSessionTabs') + } +} + +async function tabIndexOnDisk(path: string): Promise { + return JSON.parse(await readFile(path, 'utf-8')).visibleSessionIds +} + +describe('restoring the chat tabs open at quit', () => { + it.each([ + { store: 'records a newer Orca wrote', newer: true, lockFails: false }, + { store: 'a lock that keeps failing', newer: false, lockFails: true } + ])('lists and reads every chat from $store, and writes nothing', async ({ newer, lockFails }) => { + const records = [ + chatRecord(CHAT_A), + chatRecord(CHAT_B), + chatRecord(CLEARED, { clearedInto: CHAT_A }) + ] + const { storeDirectory, path } = await seedStore(records, { newer, visible: [CHAT_A, CHAT_B] }) + await seedHistory(records.slice(0, 2)) + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + const writes = spyOnTabWrites() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // Free when the store opens, then held for good. + const { runtime, onError, published } = startupRuntime({ + afterInstall: () => { + lock.failing = lockFails + } + }) + + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining( + newer ? { message: 'agent_session_legacy_required' } : { code: 'ELOCKED' } + ) + }) + expect(writes.visibility).not.toHaveBeenCalled() + expect(writes.seed).not.toHaveBeenCalled() + await stopStructuredAgentSessionRuntime() + if (newer) { + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) + } + }) + + it('costs two lock waits in all while the lock stays held, however many chats are open', async () => { + const chats = Array.from({ length: 8 }, (_, index) => `chat-${index}-000${index}`) + const records = chats.map((sessionId) => chatRecord(sessionId)) + await seedStore(records, { visible: chats }) + await seedHistory(records) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + afterInstall: () => { + lock.failing = true + } + }) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + await runtime.restoreStructuredAgentSessionTabs() + + expect(published()).toHaveLength(8) + // One for the startup reconcile, one for the restore's; the restore writes no tab. + expect(lock.refused).toBe(2) + }) + + describe('on a legacy profile, with no tab index yet', () => { + const legacyChats = () => [ + chatRecord(CHAT_A, { codex: true }), + chatRecord(CHAT_B, { codex: true }) + ] + + it('records every restored chat in one write', async () => { + const records = legacyChats() + const { path } = await seedStore(records) + await seedHistory(records) + const writes = spyOnTabWrites() + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + await runtime.prepareStructuredAgentSessionStartupRestoration() + // One more take, then held: a seed written chat by chat would stop part way. + lock.grants = 1 + + await runtime.restoreStructuredAgentSessionTabs() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(await tabIndexOnDisk(path)).toEqual([CHAT_A, CHAT_B]) + expect(writes.seed).toHaveBeenCalledOnce() + expect(writes.visibility).not.toHaveBeenCalled() + }) + + it('still lists the chats when that write fails, and leaves the index to seed again', async () => { + const records = legacyChats() + const { path } = await seedStore(records) + await seedHistory(records) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + await runtime.prepareStructuredAgentSessionStartupRestoration() + lock.failing = true + + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + + expect(published()).toHaveLength(2) + await expectHistory(CHAT_A) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording restored chat tabs failed', + { sessionIds: [CHAT_A, CHAT_B], error: expect.objectContaining({ code: 'ELOCKED' }) } + ) + expect(await tabIndexOnDisk(path)).toBeUndefined() + }) + }) +}) diff --git a/src/main/runtime/structured-agent-session-tab-index-seed.ts b/src/main/runtime/structured-agent-session-tab-index-seed.ts new file mode 100644 index 000000000000..181d67128a1f --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-index-seed.ts @@ -0,0 +1,35 @@ +// A profile saved before the store kept a tab index restores its chats from the profile's own tabs. +// Those chats are recorded once, all together: an index written part way would read as complete at +// the next launch and drop the rest. With an index present, every restored chat is already listed. + +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' + +type TabIndexHost = Pick< + StructuredAgentSessionHost, + 'getPersistedVisibleSessionTabIndex' | 'showSessionTabs' +> + +/** Best-effort: the tabs publish either way, and an index still absent is seeded again next launch. */ +export async function seedStructuredAgentSessionTabIndex( + host: Partial | null | undefined, + targets: readonly string[], + restored: readonly string[] +): Promise { + if (!host?.getPersistedVisibleSessionTabIndex || !host.showSessionTabs) { + return + } + const listed = new Set(host.getPersistedVisibleSessionTabIndex().sessionIds) + const opened = new Set(restored) + // In the restore's own order, so the seeded tabs keep the order the profile gave them. + const unlisted = [...new Set([...targets, ...restored])].filter( + (sessionId) => opened.has(sessionId) && !listed.has(sessionId) + ) + if (unlisted.length > 0) { + await host.showSessionTabs(unlisted).catch((error: unknown) => + console.warn('[structured-agent-session] recording restored chat tabs failed', { + sessionIds: unlisted, + error + }) + ) + } +} From f3ec1a117020ded62c38c5a7f34dc380a09e7d60 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:14:04 -0700 Subject: [PATCH 05/21] fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed. --- ...ured-agent-session-restart-restore.test.ts | 52 ++++++++++++++++ ...tructured-agent-session-restart-restore.ts | 21 ++++--- ...-agent-session-startup-tab-restore.test.ts | 61 +++++++++++++------ 3 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index c7d6b21ea1b0..0cffa1e32c75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -165,6 +165,58 @@ describe('restart journal restoration', () => { expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) }) + // Each failed bookkeeping call stands for one wait on a held store lock. + describe('once lease bookkeeping fails in a pass', () => { + const records = Array.from( + { length: 8 }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + const restore = ( + bookkeeping: Pick< + Parameters[0], + 'reconcile' | 'resolveRecovery' + > + ) => + restoreStructuredAgentSessionsOnRestart({ + openDeps: NO_OPEN_DEPS, + records, + ...bookkeeping, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined + }) + + /** A failure that takes a while, as a lock wait does, so the chats open at once overlap it. */ + const slowFailure = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return false + } + + beforeEach(() => restoreRead.mockResolvedValue(null)) + + it('skips it for every chat when the pass check fails, and still opens them all', async () => { + const reconcile = vi.fn(slowFailure) + const resolveRecovery = vi.fn(async () => true) + + await restore({ reconcile, resolveRecovery }) + + expect(reconcile).toHaveBeenCalledOnce() + expect(resolveRecovery).not.toHaveBeenCalled() + expect(restoreRead).toHaveBeenCalledTimes(records.length) + }) + + it('starts no more after the first failed recovery, and still opens every chat', async () => { + const resolveRecovery = vi.fn(slowFailure) + + await restore({ reconcile: async () => true, resolveRecovery }) + + // Only those already started when the first failed: at most one per chat open at once. + expect(resolveRecovery.mock.calls.length).toBeLessThanOrEqual(4) + expect(restoreRead).toHaveBeenCalledTimes(records.length) + }) + }) + it('does not settle again when a second restore finds the session already open', async () => { restoreRead.mockResolvedValue({ session: { journal: {}, params: {}, child: null }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index facc67a86a92..29ea17845d8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -72,15 +72,22 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { - // Once bookkeeping fails in this pass, trying again per chat only waits on the same store again. - let bookkeepingFailed = false + const [first] = input.records + if (!first) { + return + } + // One check for the pass. Each chat checks again while it holds, since another writer can mark + // leases unreconciled mid-pass; after the first failure, retrying per chat only waits on the + // same store again, and the next attach or send settles those chats instead. + let settled = await input.reconcile(first.sessionId) const settleLeases = async (sessionId: string): Promise => { - if (bookkeepingFailed) { - return - } // A session latched in recovery exits here at startup, without waiting for a client. - const settled = (await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId)) - bookkeepingFailed ||= !settled + if ( + settled && + !((await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId))) + ) { + settled = false + } } await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 77797a5f531b..dcf55f3207c2 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -281,25 +281,30 @@ describe('restoring the chat tabs open at quit', () => { } }) - it('costs two lock waits in all while the lock stays held, however many chats are open', async () => { - const chats = Array.from({ length: 8 }, (_, index) => `chat-${index}-000${index}`) - const records = chats.map((sessionId) => chatRecord(sessionId)) - await seedStore(records, { visible: chats }) - await seedHistory(records) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, published } = startupRuntime({ - afterInstall: () => { - lock.failing = true - } - }) + // Each refused take stands for one lock wait of about 3 s. + it.each([1, 4, 8])( + 'waits on a held lock once per startup step with %i chats open', + async (count) => { + const chats = Array.from({ length: count }, (_, index) => `chat-${index}-000${index}`) + const records = chats.map((sessionId) => chatRecord(sessionId)) + await seedStore(records, { visible: chats }) + await seedHistory(records) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + afterInstall: () => { + lock.failing = true + } + }) - await runtime.prepareStructuredAgentSessionStartupRestoration() - await runtime.restoreStructuredAgentSessionTabs() + await runtime.prepareStructuredAgentSessionStartupRestoration() + const prepared = lock.refused + await runtime.restoreStructuredAgentSessionTabs() - expect(published()).toHaveLength(8) - // One for the startup reconcile, one for the restore's; the restore writes no tab. - expect(lock.refused).toBe(2) - }) + expect(published()).toHaveLength(count) + expect(prepared).toBe(1) + expect(lock.refused - prepared).toBe(1) + } + ) describe('on a legacy profile, with no tab index yet', () => { const legacyChats = () => [ @@ -328,6 +333,28 @@ describe('restoring the chat tabs open at quit', () => { expect(writes.visibility).not.toHaveBeenCalled() }) + it('waits on a held lock once more, for that one write', async () => { + const records = legacyChats() + await seedStore(records) + await seedHistory(records) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + profileChats: [CHAT_A, CHAT_B], + afterInstall: () => { + lock.failing = true + } + }) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + const prepared = lock.refused + await runtime.restoreStructuredAgentSessionTabs() + + expect(published()).toHaveLength(2) + expect(prepared).toBe(1) + // The restore's lease check and the seed. + expect(lock.refused - prepared).toBe(2) + }) + it('still lists the chats when that write fails, and leaves the index to seed again', async () => { const records = legacyChats() const { path } = await seedStore(records) From a77e8245d166bc319251d79d5c04d5044377e4f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:27:42 -0700 Subject: [PATCH 06/21] docs(native-chat): correct restore comments and a test name to match the final design --- .../structured-agent-session-restart-reconcile.ts | 5 +++-- ...tructured-agent-session-startup-reconcile-failure.test.ts | 4 ++-- src/main/runtime/agent-session-tab-table.ts | 1 - ...rca-runtime-restore-structured-agent-session-tabs-once.ts | 2 +- .../structured-agent-session-startup-tab-restore.test.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index be2d0b6c1513..23f0e0c6bfae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,8 +45,9 @@ export function createRestartReconciler(deps: { } } -/** Reports each distinct failure once, until `clear` says the bookkeeping settled again. Every read - * re-runs a failing reconcile, so without this a failing store would log on every chat list. */ +/** Reports each distinct failure once, until `clear` says the bookkeeping settled again: the + * startup check, the restore pass and a restore retried after a failed journal open would each + * log the same store failure. */ export type ReaderBookkeepingFailures = { report: (failure: unknown) => void clear: () => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 8033843cd41e..ed18328663c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -90,7 +90,7 @@ async function relaunch( mintSpawnToken: () => 'spawn-next', probeOwner, stopOwnerProcess: () => { - throw new Error('a read must not stop an owner') + throw new Error('an owner not proven alive must not be stopped') }, now: () => NOW, onLeaseReconcileFailure @@ -180,7 +180,7 @@ it('restores a chat for reading from a store a newer Orca wrote', async () => { expect(await readFile(path)).toEqual(bytes) }) -// A chat whose owner could not be proven gone is left recovering; resolving that is the writer's. +// A chat whose owner could not be proven gone is left recovering; the next attach or send retries it. it('restores a chat for reading when resolving its recovery cannot write the store', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch(undefined, async () => ({ outcome: 'indeterminate', diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 840356cf0cf0..72551d563cab 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -131,7 +131,6 @@ export function setAgentSessionTabVisibility( } } -/** Shows each session that still has a record. */ export function showAgentSessionTabs( state: AgentSessionStoreState, sessionIds: readonly string[] diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 1eea896b5dce..10dd920b253b 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -84,7 +84,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu targets, restored.map((session) => session.sessionId) ) - // The store already lists every tab projected here, so the restore writes nothing else. + // Past the seed, projecting records nothing. for (const replacement of host?.conversationReplacements?.() ?? []) { this.replaceStructuredAgentSessionTab(replacement) } diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index dcf55f3207c2..61fa020e4276 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -355,7 +355,7 @@ describe('restoring the chat tabs open at quit', () => { expect(lock.refused - prepared).toBe(2) }) - it('still lists the chats when that write fails, and leaves the index to seed again', async () => { + it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() const { path } = await seedStore(records) await seedHistory(records) From 2a030d0c280663a7cce5561f2bb1140f027f56d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:49:46 -0700 Subject: [PATCH 07/21] fix(native-chat): keep the post-list chat restore to the chats still listed, one failure per chat The restore that reads each chat's history now runs after the tab list, while the app is already usable, so three things it did before the list existed now show: - A chat whose history failed to open ended one of the restore's four lanes. With four such chats, every chat after them got no status row until opened. Each chat now settles on its own and each failure is logged once. - A chat closed before the restore reached it was opened again and its status row republished. The restore now skips, under the chat's own serialized step, a chat whose tab is no longer listed. - After quit began, the restore kept opening chats. It now stops. Tests: the startup listing tests share the existing rest test rig, extended to several chats, instead of a parallel rig; the status parity test no longer leaves a temp directory behind. Comments that said an unreconciled lease admits no writer, or that a write waits for its own chat's check, now say what happens. --- ...red-agent-session-conversation-lifetime.ts | 9 +- .../structured-agent-session-host-tabs.ts | 9 + .../structured-agent-session-host.ts | 3 +- ...gent-session-persisted-tab-listing.test.ts | 47 ++-- ...ed-agent-session-readable-restorer.test.ts | 2 + .../structured-agent-session-rest-test-rig.ts | 186 +++++++++++--- ...uctured-agent-session-restart-reconcile.ts | 11 +- ...ured-agent-session-restart-restore.test.ts | 10 + ...tructured-agent-session-restart-restore.ts | 39 ++- ...gent-session-restore-after-listing.test.ts | 82 ++++++ ...ent-session-restore-without-import.test.ts | 2 + ...ent-session-restored-status-parity.test.ts | 33 +-- .../structured-agent-session-reveal.ts | 4 +- ...-agent-session-startup-listing-test-rig.ts | 242 ------------------ ...red-agent-session-write-lease-wait.test.ts | 30 ++- ...structured-agent-session-create-support.ts | 4 +- ...-agent-session-startup-tab-restore.test.ts | 8 +- ...-agent-session-tab-listing-startup.test.ts | 71 ++--- 18 files changed, 408 insertions(+), 384 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index f69129183b4a..44a427bd1cc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -20,6 +20,7 @@ import { import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' +import { sessionTabListed } from './structured-agent-session-host-tabs' import { adapterSupportsRecord } from './structured-agent-session-provider-support' export type StructuredAgentSessionConversationLifetime = ReturnType< @@ -54,11 +55,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { closeStructuredAgentSessionConversationUnderSerialize( { sessions, - closeStatus: (id) => { - const tabs = deps().store.getVisibleSessionTabIndex() - // A legacy store cannot say, so the row stays; restart is the boundary that forgets. - host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) - } + // A legacy store cannot say, so the row stays; restart is the boundary that forgets. + closeStatus: (id) => host.closeStatus(id, { listed: sessionTabListed(deps().store, id) }) }, sessionId ) @@ -89,6 +87,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { return { idleSweep, stopAgent, + isDisposed: (): boolean => disposed, /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index ee13814b485b..0facc7cb037e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -32,6 +32,15 @@ export function setStructuredAgentSessionTabVisibility( return host.deps.store.setSessionTabVisibility(sessionId, visible, tabId) } +/** Whether the chat still has its tab. A legacy store with no tab index cannot say, so yes. */ +export function sessionTabListed( + store: { getVisibleSessionTabIndex: () => { present: boolean; sessionIds: string[] } }, + sessionId: string +): boolean { + const tabs = store.getVisibleSessionTabIndex() + return !tabs.present || tabs.sessionIds.includes(sessionId) +} + export type StructuredAgentSessionTab = { sessionId: string workspaceId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 24f1de51ab2d..f82b8a2cec15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -142,6 +142,7 @@ export class StructuredAgentSessionHost { resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: this.hasSession, + isDisposed: () => this.lifetime.isDisposed(), // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. onReadable: this.conversationDelivery.adoptOpened @@ -275,7 +276,7 @@ export class StructuredAgentSessionHost { publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, conversation: this.lifetime.conversation, - // Every write funnels through here, so none acts on a lease the startup check has not reached. + // Every write funnels through here: one to a chat with an unchecked lease waits for the check. serialize: serializeAfterLeaseCheck(this.deps.store, this.reconcileLeases, this.serialize), openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts index f14a53ee673a..b75ab410111e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts @@ -6,14 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' import { - createStartupRig, - type StartupRig -} from './structured-agent-session-startup-listing-test-rig' + createRestTestRig, + restTestChat, + restTestOpens, + sendRestTestMessage, + type RestTestRig +} from './structured-agent-session-rest-test-rig' -let rig: StartupRig +let rig: RestTestRig beforeEach(async () => { - rig = await createStartupRig() + rig = await createRestTestRig() }) afterEach(async () => { @@ -30,8 +33,8 @@ const tab = (sessionId: string, workspaceId = 'workspace-1') => ({ describe('the tab list after a restart', () => { it('lists every chat it is given without opening one', async () => { - await rig.chat('session-a', { message: 'one' }) - await rig.chat('session-b', { message: 'two' }) + await restTestChat(rig, 'session-a', { message: 'one' }) + await restTestChat(rig, 'session-b', { message: 'two' }) await rig.crash() const host = await rig.boot() @@ -39,16 +42,16 @@ describe('the tab list after a restart', () => { tab('session-a'), tab('session-b') ]) - expect(rig.historyFilePath).not.toHaveBeenCalled() + expect(rig.adapter.historyFilePath).not.toHaveBeenCalled() }) it('keeps a chat with no history on disk, which then reads empty and takes a send (T3)', async () => { // Its first start failed before the conversation opened, so no history was ever written. - rig.acquire.mockRejectedValueOnce(new Error('spawn failed')) + rig.adapter.acquire.mockRejectedValueOnce(new Error('spawn failed')) vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await expect(rig.chat('session-new')).rejects.toThrow(/attach refused/) + await expect(restTestChat(rig, 'session-new')).rejects.toThrow(/attach refused/) await rig.store.setSessionTabVisibility('session-new', true) - await rig.chat('session-old', { message: 'hello' }) + await restTestChat(rig, 'session-old', { message: 'hello' }) await rig.crash() const host = await rig.boot() const ids = ['session-new', 'session-old'] @@ -57,22 +60,22 @@ describe('the tab list after a restart', () => { expect(host.listSessionTabs(ids)).toEqual([tab('session-new'), tab('session-old')]) // The restore founds no history for it; the tab stands anyway. - expect(rig.opensOf('session-new')).toBe(0) + expect(restTestOpens(rig, 'session-new')).toBe(0) const page = await host.history({ sessionId: 'session-new', direction: 'tail' }) expect(page.ok && page.page.items).toEqual([]) - const sent = await rig.send('session-new', 'first words') + const sent = await sendRestTestMessage(rig, 'session-new', 'first words') expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) const after = await host.history({ sessionId: 'session-new', direction: 'tail' }) expect(JSON.stringify(after.ok && after.page)).toContain('first words') }) it('keeps a chat whose history cannot be opened, and its read says why (T4)', async () => { - await rig.chat('session-bad', { message: 'lost' }) - await rig.chat('session-good', { message: 'kept' }) + await restTestChat(rig, 'session-bad', { message: 'lost' }) + await restTestChat(rig, 'session-good', { message: 'kept' }) await rig.crash() const host = await rig.boot() - rig.historyFilePath.mockImplementation(async (sessionId) => { + rig.adapter.historyFilePath.mockImplementation(async (sessionId) => { if (sessionId === 'session-bad') { throw new Error('EACCES: permission denied') } @@ -94,9 +97,9 @@ describe('the tab list after a restart', () => { }) it('lists in the order it is given, once each, whatever order the records are in (T7)', async () => { - await rig.chat('session-c') - await rig.chat('session-a') - await rig.chat('session-b') + await restTestChat(rig, 'session-c') + await restTestChat(rig, 'session-a') + await restTestChat(rig, 'session-b') await rig.crash() const host = await rig.boot() @@ -108,8 +111,8 @@ describe('the tab list after a restart', () => { }) it('lists no chat without a record, and none this host cannot serve (T10, regression guard)', async () => { - await rig.chat('session-served', { message: 'here' }) - await rig.chat('session-gated', { workspaceId: 'workspace-gated', message: 'there' }) + await restTestChat(rig, 'session-served', { message: 'here' }) + await restTestChat(rig, 'session-gated', { workspaceId: 'workspace-gated', message: 'there' }) await rig.crash() const host = await rig.boot() rig.unsupportedWorkspaceIds.add('workspace-gated') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 09cab618ab9a..12e52293801f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -43,6 +43,8 @@ describe('StructuredAgentSessionReadableRestorer', () => { resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, + isDisposed: () => false, + isListed: () => true, onReadable: () => undefined }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 782c1f161156..6ae31f023cf0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -1,6 +1,8 @@ // A real host over a real store and journal, with a scripted provider and a clock the test moves, -// for the tests of a conversation that outlives its agent. The idle sweep runs on its own short -// interval; a test moves `clock.now` past the idle window and waits for the outcome. +// for the tests of a conversation that outlives its agent and of what a restarted host owes. The +// idle sweep runs on its own short interval; a test moves `clock.now` past the idle window and +// waits for the outcome. Every journal open calls `historyFilePath` once, so that mock is the open +// counter, and holding it holds the open. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,6 +10,8 @@ import { join } from 'node:path' import { expect, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope, AgentSessionStatusEvent, @@ -21,6 +25,7 @@ import type { import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { + HOST_TEST_LOCATION, HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -45,6 +50,8 @@ export type RestTestAdapter = { > backgroundTaskState: Mock> readOptions: Mock> + /** Called once per journal open, with the session id; replace its implementation to hold one. */ + historyFilePath: Mock<(sessionId: string) => Promise> } export type RestTestRig = { @@ -55,36 +62,55 @@ export type RestTestRig = { clock: { now: number } statusEvents: AgentSessionStatusEvent[] sink: { publish: Mock; forget: Mock } + probeOwner: Mock<(record: AgentSessionRecord) => Promise> + /** Workspaces this host's adapter does not serve, as a platform gate would. */ + unsupportedWorkspaceIds: Set /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise + /** A crash: nothing settles or flushes; the process's timers and handles simply stop. */ + crash: () => Promise + /** A new app run over the same files, with no flush first; the mocks and status stream start + * empty, so they hold what the new run saw and nothing the previous one did. */ + boot: (deps?: Partial) => Promise dispose: () => Promise } let ordinal = 0 let generations = 0 -export function acceptedDispatch(): AgentSessionDispatchOutcome { +/** The rig's own chat keeps the shared test thread; any other chat gets a thread of its own. */ +function restTestThread(sessionId: string): string { + return sessionId === SESSION ? THREAD : `thread-${sessionId}` +} + +export function acceptedDispatch(sessionId = SESSION): AgentSessionDispatchOutcome { ordinal += 1 return { state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: `turn-${ordinal}`, ordinal } + providerIdentity: { + provider: 'codex', + threadId: restTestThread(sessionId), + turnId: `turn-${ordinal}`, + ordinal + } } } export function restTestSend( text: string, - fence = 1 + fence: number | null = 1, + sessionId = SESSION ): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { const body = hostTestMessage(text) return { body, envelope: { - sessionId: SESSION, + sessionId, clientOperationId: hostTestOperationId(), expectedRuntimeFence: fence, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', - sessionId: SESSION, + sessionId, fields: { body } }) } @@ -117,27 +143,31 @@ export function collectSubscriber(): { return { events, emit: (event) => events.push(event) } } +/** `root` is handed to the rig, which removes it on dispose; by default it makes its own. */ export async function createRestTestRig( - deps: Partial = {} + deps: Partial = {}, + options: { root?: string } = {} ): Promise { resetHostTestOperationIds() ordinal = 0 - const root = await mkdtemp(join(tmpdir(), 'orca-rest-')) + const root = options.root ?? (await mkdtemp(join(tmpdir(), 'orca-rest-'))) const clock = { now: HOST_TEST_NOW } const statusEvents: AgentSessionStatusEvent[] = [] const sink = { publish: vi.fn(), forget: vi.fn() } - let store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const probeOwner: RestTestRig['probeOwner'] = vi.fn(async () => ({ outcome: 'pid-absent' })) + const unsupportedWorkspaceIds = new Set() + const openStore = () => + AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + let store = await openStore() const adapter: RestTestAdapter = { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ + acquire: vi.fn(async ({ fence, spawnToken, identity }) => ({ acquisitionGeneration: `generation-${++generations}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { - linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, - origin: store.getRecord(SESSION)?.providerHandleChain.length + linkId: + identity.sessionId === SESSION ? `link-${fence}` : `link-${identity.sessionId}-${fence}`, + handle: { provider: 'codex' as const, threadId: restTestThread(identity.sessionId) }, + origin: store.getRecord(identity.sessionId)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, @@ -145,16 +175,20 @@ export async function createRestTestRig( } })), closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => acceptedDispatch()), + dispatch: vi.fn(async (input) => acceptedDispatch(input.sessionId)), acknowledgeSessionRelease: vi.fn(), backgroundTaskState: vi.fn(() => undefined), - readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) + readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), + historyFilePath: vi.fn(async (_sessionId: string): Promise => null) } - const hostFor = (overrides: Partial) => - new StructuredAgentSessionHost({ + const hostFor = (overrides: Partial) => { + const host = new StructuredAgentSessionHost({ store, adapter: { ...adapter, + historyFilePath: ({ identity }) => adapter.historyFilePath(identity.sessionId), + supportsCreate: (location, agent) => + agent === 'codex' && !unsupportedWorkspaceIds.has(location.workspaceId), releaseAcquisition: vi.fn(async () => true), cancelTurn: async () => ({ cancelled: true }), answerPrompt: async ({ commit }) => commit(), @@ -163,13 +197,22 @@ export async function createRestTestRig( journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner, now: () => clock.now, statusSink: sink, idleSweep: { intervalMs: SWEEP_INTERVAL_MS }, ...deps, ...overrides }) + host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return host + } + const reopen = async (overrides: Partial) => { + store = await openStore() + rig.store = store + rig.host = hostFor(overrides) + return rig.host + } const rig: RestTestRig = { root, store, @@ -178,38 +221,107 @@ export async function createRestTestRig( clock, statusEvents, sink, + probeOwner, + unsupportedWorkspaceIds, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) - store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) - rig.store = store - rig.host = hostFor(overrides) - rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) - return rig.host + return reopen(overrides) + }, + crash: async () => { + const { runtimeState, lifetime, sessions } = rig.host.collaboratorsForTests() + await runtimeState.stopLeaseRenewal() + lifetime.dispose() + for (const session of sessions.values()) { + await session.journal.close() + } + sessions.clear() + }, + boot: (overrides = {}) => { + for (const mock of [adapter.historyFilePath, adapter.acquire, adapter.dispatch, probeOwner]) { + mock.mockClear() + } + sink.publish.mockClear() + sink.forget.mockClear() + statusEvents.length = 0 + return reopen(overrides) }, dispose: async () => { await rig.host.flushAllStreamedEvents().catch(() => undefined) await rm(root, { recursive: true, force: true }) } } - rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) return rig } -/** Creates the chat, lists its tab, and sends one message so its journal is on disk. */ -export async function foundRestTestChat(rig: RestTestRig): Promise { - const attached = await rig.host.attach(REST_TEST_CALLER, hostTestAttachParams(null)) +/** Creates the chat, lists its tab unless told not to, and sends `message` when one is given, + * waiting for its dispatch. */ +export async function restTestChat( + rig: RestTestRig, + sessionId: string, + options: { workspaceId?: string; listed?: boolean; message?: string } = {} +): Promise { + const fence = rig.store.getRecord(sessionId)?.lease.runtimeFence ?? null + const attached = await rig.host.attach( + REST_TEST_CALLER, + hostTestAttachParams(fence, { + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + }, + location: { ...HOST_TEST_LOCATION, workspaceId: options.workspaceId ?? 'workspace-1' }, + providerHandle: { kind: 'codex', threadId: restTestThread(sessionId) } + }) + ) if (!attached.ok) { throw new Error(`attach refused: ${attached.refusal.code}`) } - await rig.store.setSessionTabVisibility(SESSION, true) - const sent = await rig.host.send(REST_TEST_CALLER, restTestSend('hello', attached.fence)) + if (options.listed !== false) { + await rig.store.setSessionTabVisibility(sessionId, true) + } + if (options.message === undefined) { + return + } + const dispatched = rig.adapter.dispatch.mock.calls.length + const sent = await rig.host.send( + REST_TEST_CALLER, + restTestSend(options.message, attached.fence, sessionId) + ) if (!sent.ok) { throw new Error(`send refused: ${sent.refusal.code}`) } - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalled()) + await vi.waitFor(() => expect(rig.adapter.dispatch.mock.calls.length).toBeGreaterThan(dispatched)) +} + +/** Creates the rig's own chat, lists its tab, and sends one message so its journal is on disk. */ +export function foundRestTestChat(rig: RestTestRig): Promise { + return restTestChat(rig, SESSION, { message: 'hello' }) +} + +/** A send from a client that has not attached this run, so it names no fence. */ +export function sendRestTestMessage(rig: RestTestRig, sessionId: string, text: string) { + return rig.host.send(REST_TEST_CALLER, restTestSend(text, null, sessionId)) +} + +export function restTestOpens(rig: RestTestRig, sessionId: string): number { + return rig.adapter.historyFilePath.mock.calls.filter(([id]) => id === sessionId).length +} + +/** The newest row the status stream carried for a session. */ +export function latestRestTestStatus(rig: RestTestRig, sessionId: string) { + for (const event of rig.statusEvents.toReversed()) { + if (event.type === 'status' && event.session.sessionId === sessionId) { + return event.session + } + if (event.type === 'snapshot') { + const found = event.sessions.find((session) => session.sessionId === sessionId) + if (found) { + return found + } + } + } + return undefined } /** Runs one sweep pass now, for a test that set `idleSweep.intervalMs` out of reach. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index c4a6df4be312..1c4274da9719 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -106,11 +106,12 @@ function failureKey(failure: unknown): string { return String(failure) } -/** The chat's queue, entered by a write only once that chat's lease is checked. Startup does not - * wait for the lease check, so a write can reach a chat whose lease is still the previous run's; - * it waits for that check, or runs it. With none owed it enqueues at once, keeping its place. A - * check that fails gates nothing: the lease stays unreconciled, which admits no writer, and the - * next start checks again. */ +/** The chat's queue, entered by a write to a chat whose lease is still the previous run's only + * after the lease check, which startup does not wait for. The write waits for that check (one pass + * over every unreconciled lease, not only this chat's), or runs it. With none owed it enqueues at + * once, keeping its place. A check that fails gates nothing: a conversation write (a send, a Stop) + * is still admitted, a provider-child write is refused while the lease stays unreconciled, and an + * agent start reconciles again and refuses. */ export function serializeAfterLeaseCheck( store: Pick, reconcile: (sessionId: string) => Promise, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 0cffa1e32c75..7540e8159744 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -66,6 +66,8 @@ describe('restart journal restoration', () => { resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, + isDisposed: () => false, + isListed: () => true, onReadable: () => undefined }) @@ -107,6 +109,8 @@ describe('restart journal restoration', () => { resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, + isDisposed: () => false, + isListed: () => true, onReadable: () => undefined }) ticking = false @@ -157,6 +161,8 @@ describe('restart journal restoration', () => { }, serialize: async (_sessionId, task) => task(), hasSession: () => false, + isDisposed: () => false, + isListed: () => true, onReadable: (_sessionId, readable) => { calls.push(readable === restored ? 'onReadable:restored' : 'onReadable') } @@ -184,6 +190,8 @@ describe('restart journal restoration', () => { ...bookkeeping, serialize: async (_sessionId, task) => task(), hasSession: () => false, + isDisposed: () => false, + isListed: () => true, onReadable: () => undefined }) @@ -231,6 +239,8 @@ describe('restart journal restoration', () => { resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => true, + isDisposed: () => false, + isListed: () => true, onReadable: () => undefined }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 29ea17845d8a..fd621db47cbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -13,7 +13,7 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' +import { mapSettledWithConcurrency } from '../../../shared/map-with-concurrency' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { OpenedStructuredAgentSessionConversation, @@ -34,6 +34,10 @@ export type StructuredAgentSessionReadRestoreDeps = { resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean + /** Quit has begun: the restore opens nothing more. */ + isDisposed: () => boolean + /** Whether the chat still has its tab: one closed while the restore ran stays closed. */ + isListed: (sessionId: string) => boolean onReadable: ( sessionId: string, opened: OpenedStructuredAgentSessionConversation @@ -46,6 +50,9 @@ async function restoreOneStructuredAgentSessionRead( sessionId: string, settleLeases: (sessionId: string) => Promise ): Promise { + if (input.isDisposed()) { + return + } await settleLeases(sessionId) await input.serialize(sessionId, () => restoreOneStructuredAgentSessionReadUnderSerialize(input, sessionId) @@ -54,9 +61,16 @@ async function restoreOneStructuredAgentSessionRead( /** The serialized half of the restore. */ async function restoreOneStructuredAgentSessionReadUnderSerialize( - input: Pick, + input: Pick< + StructuredAgentSessionReadRestoreDeps, + 'openDeps' | 'hasSession' | 'isDisposed' | 'isListed' | 'onReadable' + >, sessionId: string ): Promise { + // Checked under the chat's serialize, where its close and quit's teardown also run. + if (input.isDisposed() || !input.isListed(sessionId)) { + return + } if (input.hasSession(sessionId)) { // A read or a send mid-restore already opened this one. return @@ -89,9 +103,22 @@ export async function restoreStructuredAgentSessionsOnRestart( settled = false } } - await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { - // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. - await yieldToEventLoop() - await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases) + const results = await mapSettledWithConcurrency( + input.records, + JOURNAL_RESTORE_CONCURRENCY, + async ({ sessionId }) => { + // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. + await yieldToEventLoop() + await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases) + } + ) + // One chat's failure costs only that chat, which opens again when it is read. + results.forEach((result, index) => { + if (result.status === 'rejected') { + console.warn('[structured-agent-session] restoring a chat for reading failed', { + sessionId: input.records[index]?.sessionId, + error: result.reason + }) + } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts new file mode 100644 index 000000000000..9752089002b9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts @@ -0,0 +1,82 @@ +// The restore pass runs after the tab list is out, so the app is already usable while it works +// through the chats. A chat closed, or a host quit, before the pass reaches it stays that way. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' +import { + createRestTestRig, + restTestChat, + restTestOpens, + type RestTestRig +} from './structured-agent-session-rest-test-rig' + +const IDS = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5'] +const LAST = 'session-5' + +let rig: RestTestRig +let releaseHeld = (): void => undefined + +beforeEach(async () => { + rig = await createRestTestRig() +}) + +afterEach(async () => { + releaseHeld() + await rig.dispose() + closeTestJournalHostDatabases() + vi.restoreAllMocks() +}) + +/** Boots over a crashed run and starts the pass with the first four opens held, so it has not + * reached the last chat. */ +async function bootWithHeldPass() { + for (const sessionId of IDS) { + await restTestChat(rig, sessionId, { message: `asked ${sessionId}` }) + } + await rig.crash() + const host = await rig.boot() + const held = Promise.withResolvers() + releaseHeld = held.resolve + rig.adapter.historyFilePath.mockImplementation(async (sessionId) => { + if (sessionId !== LAST) { + await held.promise + } + return null + }) + const pass = host.restoreReadableSessions(IDS) + await vi.waitFor(() => expect(rig.adapter.historyFilePath).toHaveBeenCalledTimes(4)) + return { host, pass, release: held.resolve } +} + +const publishedFor = (sessionId: string): number => + rig.sink.publish.mock.calls.filter(([summary]) => summary.sessionId === sessionId).length + +describe('the restore pass after the tab list', () => { + it('does not reopen a chat closed before the pass reached it', async () => { + const { host, pass, release } = await bootWithHeldPass() + + // What closing the tab does. + await host.setSessionTabVisibility(LAST, false) + await host.close(LAST) + const publishedAtClose = publishedFor(LAST) + release() + await pass + + expect(host.hasSession(LAST)).toBe(false) + expect(restTestOpens(rig, LAST)).toBe(0) + expect(publishedFor(LAST)).toBe(publishedAtClose) + expect(host.hasSession('session-1')).toBe(true) + }) + + it('opens nothing more once the host is quitting', async () => { + const { host, pass, release } = await bootWithHeldPass() + + const teardown = host.flushAllStreamedEvents() + release() + await teardown + await pass + + expect(restTestOpens(rig, LAST)).toBe(0) + expect(host.hasSession(LAST)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 3290ba05f0de..7a5d2402d8e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -236,6 +236,8 @@ async function restore(sessionIds: readonly string[]) { resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: (sessionId) => sessions.has(sessionId), + isDisposed: () => false, + isListed: () => true, onReadable: (sessionId, opened) => { sessions.set(sessionId, opened.session) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts index b0c033c2f84b..ac399f72e8d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts @@ -7,12 +7,13 @@ import { afterEach, expect, it, vi } from 'vitest' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' import { - createStartupRig, - latestStatus, - type StartupRig -} from './structured-agent-session-startup-listing-test-rig' + createRestTestRig, + latestRestTestStatus, + restTestChat, + type RestTestRig +} from './structured-agent-session-rest-test-rig' -const rigs: StartupRig[] = [] +const rigs: RestTestRig[] = [] afterEach(async () => { for (const rig of rigs.splice(0)) { @@ -26,14 +27,14 @@ const CORPUS = ['session-settled', 'session-quiet', 'session-crashed', 'session- /** The chats a restart finds: two a clean quit settled, one cut off mid-turn by a crash, and one * whose send the provider only admitted, left pending below a fence that has since moved. */ -async function seedCorpus(rig: StartupRig): Promise { - await rig.chat('session-settled', { message: 'finished work' }) - await rig.chat('session-quiet') +async function seedCorpus(rig: RestTestRig): Promise { + await restTestChat(rig, 'session-settled', { message: 'finished work' }) + await restTestChat(rig, 'session-quiet') await rig.host.flushAllStreamedEvents() await rig.boot() - await rig.chat('session-crashed', { message: 'cut off' }) - rig.dispatch.mockResolvedValueOnce({ state: 'admitted' }) - await rig.chat('session-pending', { message: 'only admitted' }) + await restTestChat(rig, 'session-crashed', { message: 'cut off' }) + rig.adapter.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + await restTestChat(rig, 'session-pending', { message: 'only admitted' }) await rig.crash() await rig.store.transitionHandoff('session-pending', (record) => ({ ...record, @@ -42,14 +43,14 @@ async function seedCorpus(rig: StartupRig): Promise { } it('ends every chat where a direct read of it lands, and shows no pre-crash work (T8)', async () => { - const passRig = await createStartupRig() + const passRig = await createRestTestRig() rigs.push(passRig) await seedCorpus(passRig) // One copy of the files for each boot, taken while nothing is writing. closeTestJournalHostDatabases() const readRoot = `${passRig.root}-read` await cp(passRig.root, readRoot, { recursive: true }) - const readRig = await createStartupRig(readRoot) + const readRig = await createRestTestRig({}, { root: readRoot }) rigs.push(readRig) // Startup's shape: the lease check started, the list answered, then the pass. @@ -67,10 +68,10 @@ it('ends every chat where a direct read of it lands, and shows no pre-crash work await readCheck // `updatedAt` is when the row was projected, a wall-clock read that differs between two boots. - const rows = (rig: StartupRig): Record> => + const rows = (rig: RestTestRig): Record> => Object.fromEntries( CORPUS.flatMap((sessionId) => { - const summary = latestStatus(rig, sessionId) + const summary = latestRestTestStatus(rig, sessionId) if (!summary) { return [] } @@ -80,7 +81,7 @@ it('ends every chat where a direct read of it lands, and shows no pre-crash work ) await vi.waitFor(() => expect(rows(passRig)).toEqual(rows(readRig))) for (const sessionId of CORPUS) { - expect(latestStatus(passRig, sessionId)).toBeDefined() + expect(latestRestTestStatus(passRig, sessionId)).toBeDefined() } const crashedRows = passRig.statusEvents.flatMap((event) => event.type === 'status' && event.session.sessionId === 'session-crashed' ? [event.session] : [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 262c9e18d3c6..ad03ad82e8cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -12,6 +12,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { sessionTabListed } from './structured-agent-session-host-tabs' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' @@ -62,7 +63,7 @@ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' | 'reconcile' | 'resolveRecovery' + 'openDeps' | 'supportsRecord' | 'reconcile' | 'resolveRecovery' | 'isListed' > & { reconcileLeases: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise @@ -77,6 +78,7 @@ export function createStructuredAgentSessionHostRestore( const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + isListed: (sessionId) => sessionTabListed(deps.store, sessionId), reconcile, // The next attach or send resolves recovery again, strictly, before it acts. resolveRecovery: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts deleted file mode 100644 index 479f9b4df51b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig.ts +++ /dev/null @@ -1,242 +0,0 @@ -// Real hosts over one real record store and journal database, booted in turn the way app runs -// follow each other, for the tests of what a restarted host lists and owes before anyone opens a -// chat. Every journal open calls the adapter's `historyFilePath` once, so that is the open counter, -// and holding it holds the open. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -import { - HOST_TEST_LOCATION, - HOST_TEST_NOW, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' - -export const STARTUP_CALLER = { callerKey: 'client-1' } - -export type StartupRig = { - root: string - store: AgentSessionRecordStore - host: StructuredAgentSessionHost - acquire: Mock - dispatch: Mock - /** Called once per journal open, with the session id; replace its implementation to hold one. */ - historyFilePath: Mock<(sessionId: string) => Promise> - probeOwner: Mock<(record: AgentSessionRecord) => Promise> - /** Workspaces this host's adapter does not serve, as a platform gate would. */ - unsupportedWorkspaceIds: Set - statusEvents: AgentSessionStatusEvent[] - /** Creates the chat, lists its tab unless told not to, and sends `message` when one is given, - * waiting for its dispatch. */ - chat: ( - sessionId: string, - options?: { workspaceId?: string; listed?: boolean; message?: string } - ) => Promise - send: (sessionId: string, text: string) => ReturnType - opensOf: (sessionId: string) => number - /** A crash: nothing settles or flushes; the process's timers and handles simply stop. */ - crash: () => Promise - /** A new app run over the same files; the counters and status stream start empty. */ - boot: (deps?: Partial) => Promise - dispose: () => Promise -} - -export function startupSendEnvelope( - sessionId: string, - body: ReturnType -): AgentSessionMutationEnvelope { - return { - sessionId, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) - } -} - -function startupAttachParams( - sessionId: string, - workspaceId: string, - fence: number | null -): AgentSessionAttachParams { - return hostTestAttachParams(fence, { - envelope: { - sessionId, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: fence, - payloadFingerprint: '' - }, - location: { ...HOST_TEST_LOCATION, workspaceId }, - providerHandle: { kind: 'codex', threadId: `thread-${sessionId}` } - }) -} - -export async function createStartupRig( - root?: string, - deps: Partial = {} -): Promise { - resetHostTestOperationIds() - const stateRoot = root ?? (await mkdtemp(join(tmpdir(), 'orca-startup-listing-'))) - const directory = join(stateRoot, 'store') - const statusEvents: AgentSessionStatusEvent[] = [] - const historyFilePath = vi.fn(async (_sessionId: string): Promise => null) - const probeOwner: StartupRig['probeOwner'] = vi.fn(async () => ({ outcome: 'pid-absent' })) - const unsupportedWorkspaceIds = new Set() - let ordinal = 0 - const acquire: StartupRig['acquire'] = vi.fn(async ({ fence, spawnToken, identity }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${identity.sessionId}-${fence}`, - handle: { provider: 'codex' as const, threadId: `thread-${identity.sessionId}` }, - origin: rig.store.getRecord(identity.sessionId)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - })) - const dispatch: StartupRig['dispatch'] = vi.fn(async (input) => { - ordinal += 1 - return { - state: 'accepted', - providerIdentity: { - provider: 'codex', - threadId: `thread-${input.sessionId}`, - turnId: `turn-${ordinal}`, - ordinal - } - } - }) - const open = async (overrides: Partial) => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - const host = new StructuredAgentSessionHost({ - store, - adapter: { - acquire, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async ({ commit }) => commit(), - setOption: async () => undefined, - historyFilePath: ({ identity }) => historyFilePath(identity.sessionId), - supportsCreate: (location, agent) => - agent === 'codex' && !unsupportedWorkspaceIds.has(location.workspaceId) - }, - journalDatabase: openTestJournalHostDatabase(stateRoot), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - probeOwner, - now: () => HOST_TEST_NOW, - ...deps, - ...overrides - }) - host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) - return { store, host } - } - const first = await open({}) - const rig: StartupRig = { - root: stateRoot, - ...first, - acquire, - dispatch, - historyFilePath, - probeOwner, - unsupportedWorkspaceIds, - statusEvents, - chat: async (sessionId, options = {}) => { - const attached = await rig.host.attach( - STARTUP_CALLER, - startupAttachParams( - sessionId, - options.workspaceId ?? 'workspace-1', - rig.store.getRecord(sessionId)?.lease.runtimeFence ?? null - ) - ) - if (!attached.ok) { - throw new Error(`attach refused: ${attached.refusal.code}`) - } - if (options.listed !== false) { - await rig.store.setSessionTabVisibility(sessionId, true) - } - if (options.message === undefined) { - return - } - const dispatched = dispatch.mock.calls.length - const sent = await rig.send(sessionId, options.message) - if (!sent.ok) { - throw new Error(`send refused: ${sent.refusal.code}`) - } - await vi.waitFor(() => expect(dispatch.mock.calls.length).toBeGreaterThan(dispatched)) - }, - send: (sessionId, text) => { - const body = hostTestMessage(text) - return rig.host.send(STARTUP_CALLER, { body, envelope: startupSendEnvelope(sessionId, body) }) - }, - opensOf: (sessionId) => historyFilePath.mock.calls.filter(([id]) => id === sessionId).length, - crash: async () => { - const { runtimeState, lifetime, sessions } = rig.host.collaboratorsForTests() - await runtimeState.stopLeaseRenewal() - lifetime.dispose() - for (const session of sessions.values()) { - await session.journal.close() - } - sessions.clear() - }, - boot: async (overrides = {}) => { - // What the new run saw, and nothing the previous one did. - historyFilePath.mockClear() - acquire.mockClear() - dispatch.mockClear() - probeOwner.mockClear() - statusEvents.length = 0 - const next = await open(overrides) - rig.store = next.store - rig.host = next.host - return next.host - }, - dispose: async () => { - await rig.host.flushAllStreamedEvents().catch(() => undefined) - if (!root) { - await rm(stateRoot, { recursive: true, force: true }) - } - } - } - return rig -} - -/** The newest row the status stream carried for a session. */ -export function latestStatus(rig: StartupRig, sessionId: string) { - for (const event of rig.statusEvents.toReversed()) { - if (event.type === 'status' && event.session.sessionId === sessionId) { - return event.session - } - if (event.type === 'snapshot') { - const found = event.sessions.find((session) => session.sessionId === sessionId) - if (found) { - return found - } - } - } - return undefined -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts index 93899e5c17a9..18bb1714d92b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts @@ -1,23 +1,25 @@ // Startup no longer waits for the lease check, so a write can reach a chat whose lease the previous -// app run left unadjudicated. Every write waits for that chat's check (or runs it) before it acts, -// so none is admitted against the old lease and none starts a second agent. +// app run left unadjudicated. Such a write waits for the lease check (or runs it) before it acts, +// so while that check succeeds none is admitted against the old lease. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' import { hostTestOperationId } from './structured-agent-session-host-test-data' import { - createStartupRig, - STARTUP_CALLER, - type StartupRig -} from './structured-agent-session-startup-listing-test-rig' + createRestTestRig, + REST_TEST_CALLER, + restTestChat, + sendRestTestMessage, + type RestTestRig +} from './structured-agent-session-rest-test-rig' -let rig: StartupRig +let rig: RestTestRig // A failed assertion must not leave teardown waiting on a held probe. let releaseProbe = (): void => undefined beforeEach(async () => { - rig = await createStartupRig() + rig = await createRestTestRig() }) afterEach(async () => { @@ -29,7 +31,7 @@ afterEach(async () => { /** Boots over a crashed run and starts the startup lease check with its owner probe held. */ async function bootWithSlowLeaseCheck() { - await rig.chat('session-a', { message: 'before the restart' }) + await restTestChat(rig, 'session-a', { message: 'before the restart' }) await rig.crash() const probe = Promise.withResolvers() const probing = Promise.withResolvers() @@ -64,7 +66,7 @@ describe('a write during a slow startup lease check', () => { const { probe, startupCheck, admittedUnreconciled } = await bootWithSlowLeaseCheck() let answered = false - const sent = rig.send('session-a', 'after the restart').then((result) => { + const sent = sendRestTestMessage(rig, 'session-a', 'after the restart').then((result) => { answered = true return result }) @@ -75,17 +77,17 @@ describe('a write during a slow startup lease check', () => { probe.resolve() await expect(sent).resolves.toMatchObject({ ok: true }) await startupCheck - await vi.waitFor(() => expect(rig.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) expect(admittedUnreconciled).toEqual([false]) - expect(rig.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.acquire).toHaveBeenCalledOnce() }) it('holds a Stop until the chat’s check has run', async () => { const { host, probe, startupCheck, admittedUnreconciled } = await bootWithSlowLeaseCheck() const fields = {} - const stopped = host.cancel(STARTUP_CALLER, { + const stopped = host.cancel(REST_TEST_CALLER, { envelope: { sessionId: 'session-a', clientOperationId: hostTestOperationId(), @@ -104,6 +106,6 @@ describe('a write during a slow startup lease check', () => { await expect(stopped).resolves.toMatchObject({ ok: true }) await startupCheck expect(admittedUnreconciled).toEqual([false]) - expect(rig.acquire).not.toHaveBeenCalled() + expect(rig.adapter.acquire).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 608ddbceebd9..af7c4aa80d67 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -253,8 +253,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR } /** Startup's structured step: the host and PTY inventory, awaited; the lease check, started and - * never awaited. Neither the tab list nor paint needs leases, and every write to a chat whose - * lease is unchecked waits for its own check (`serializeAfterLeaseCheck`). */ + * never awaited. Neither the tab list nor paint needs leases, and a write to a chat whose lease + * is unchecked first waits for that check (`serializeAfterLeaseCheck`). */ async prepareStructuredAgentSessionStartupRestoration(): Promise { await this.ensureStructuredAgentSessionInventory() if (this.hasPersistedStructuredAgentSessionStore()) { diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index b5ad33204087..79ad60a5486e 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -1,7 +1,7 @@ -// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), -// which reads every chat whose tab was open at quit. That read must not wait on record-store -// bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs -// a bounded number of lock waits, not one per chat. +// With native chat on, the renderer's startup also awaits the chat tab list (`session.tabs.listAll`). +// The list opens no chat; a restore it does not wait for then reads every chat whose tab was open at +// quit. Neither may wait on record-store bookkeeping: with a saved tab index they write nothing, and +// a store that cannot be written costs a bounded number of lock waits, not one per chat. import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts index 239f10627660..9c8687d524a0 100644 --- a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -7,10 +7,11 @@ import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { - createStartupRig, - latestStatus, - type StartupRig -} from '../native-chat/agent-session-wire/structured-agent-session-startup-listing-test-rig' + createRestTestRig, + latestRestTestStatus, + restTestChat, + type RestTestRig +} from '../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' import { OrcaRuntimeService } from './orca-runtime' import { RpcDispatcher } from './rpc/dispatcher' import { SESSION_TAB_METHODS } from './rpc/methods/session-tabs' @@ -31,16 +32,20 @@ const CONTEXT = { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } -let rig: StartupRig +let rig: RestTestRig // Anything a test holds is let go before teardown, so a failed assertion cannot hang it. let releaseHeld = (): void => undefined beforeEach(async () => { - rig = await createStartupRig() + rig = await createRestTestRig() }) afterEach(async () => { releaseHeld() + // The listing starts the lease check and the restore without awaiting either; both finish before + // the rig removes their files. + await rig.host.reconcileRestartLeases() + await rig.host.restoreReadableSessions() setStructuredAgentSessionHost(null) await rig.dispose() closeTestJournalHostDatabases() @@ -93,14 +98,14 @@ describe('listing chat tabs at startup', () => { it('answers before any chat opens, then opens each one (T1)', async () => { const ids = ['session-1', 'session-2', 'session-3'] for (const sessionId of ids) { - await rig.chat(sessionId, { message: sessionId }) + await restTestChat(rig, sessionId, { message: sessionId }) } await rig.crash() await rig.boot() const opens = Promise.withResolvers() releaseHeld = opens.resolve let opened = 0 - rig.historyFilePath.mockImplementation(async () => { + rig.adapter.historyFilePath.mockImplementation(async () => { await opens.promise opened += 1 return null @@ -116,8 +121,8 @@ describe('listing chat tabs at startup', () => { }) it('answers while the startup lease check never finishes (T2)', async () => { - await rig.chat('session-1', { message: 'one' }) - await rig.chat('session-2', { message: 'two' }) + await restTestChat(rig, 'session-1', { message: 'one' }) + await restTestChat(rig, 'session-2', { message: 'two' }) await rig.crash() const probe = Promise.withResolvers() releaseHeld = probe.resolve @@ -137,7 +142,7 @@ describe('listing chat tabs at startup', () => { }) it('answers when the startup lease check fails (T2)', async () => { - await rig.chat('session-1', { message: 'one' }) + await restTestChat(rig, 'session-1', { message: 'one' }) await rig.crash() rig.probeOwner.mockRejectedValue(new Error('probe unavailable')) vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -154,15 +159,18 @@ describe('listing chat tabs at startup', () => { ) }) - it('lists every chat when one fails to open, and the others still get status rows (T5)', async () => { - const ids = ['session-1', 'session-2', 'session-3'] + it('lists every chat when four fail to open, and the others still get status rows (T5)', async () => { + // More chats than the restore opens at once, with the first four failing: each failure must + // cost only its own chat, not one of the restore's four lanes. + const ids = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6'] + const failing = ids.slice(0, 4) for (const sessionId of ids) { - await rig.chat(sessionId, { message: `asked ${sessionId}` }) + await restTestChat(rig, sessionId, { message: `asked ${sessionId}` }) } await rig.crash() await rig.boot() - rig.historyFilePath.mockImplementation(async (sessionId) => { - if (sessionId === 'session-2') { + rig.adapter.historyFilePath.mockImplementation(async (sessionId) => { + if (failing.includes(sessionId)) { throw new Error('EACCES: permission denied') } return null @@ -172,26 +180,31 @@ describe('listing chat tabs at startup', () => { const { listAll } = restartedRuntime() expect(await within(listAll())).toEqual(ids) - await vi.waitFor(() => { - expect(latestStatus(rig, 'session-1')).toMatchObject({ latestPrompt: 'asked session-1' }) - expect(latestStatus(rig, 'session-3')).toMatchObject({ latestPrompt: 'asked session-3' }) - }) - await vi.waitFor(() => - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] restoring chat history after listing failed', - expect.objectContaining({ message: 'EACCES: permission denied' }) + await restore.mock.results[0]?.value + for (const sessionId of ['session-5', 'session-6']) { + expect(rig.host.hasSession(sessionId)).toBe(true) + expect(latestRestTestStatus(rig, sessionId)).toMatchObject({ + latestPrompt: `asked ${sessionId}` + }) + } + const failures = warn.mock.calls.filter( + ([message]) => message === '[structured-agent-session] restoring a chat for reading failed' + ) + expect(failures.map(([, detail]) => detail)).toEqual( + failing.map((sessionId) => + expect.objectContaining({ + sessionId, + error: expect.objectContaining({ message: 'EACCES: permission denied' }) + }) ) ) expect(await listAll()).toEqual(ids) expect(restore).toHaveBeenCalledOnce() - expect( - warn.mock.calls.filter(([message]) => String(message).includes('after listing')) - ).toHaveLength(1) }) it('lists in tab-table order, not the order the chats were created (T7)', async () => { for (const sessionId of ['session-c', 'session-a', 'session-b']) { - await rig.chat(sessionId, { listed: false }) + await restTestChat(rig, sessionId, { listed: false }) } for (const sessionId of ['session-b', 'session-c', 'session-a']) { await rig.store.setSessionTabVisibility(sessionId, true) @@ -205,7 +218,7 @@ describe('listing chat tabs at startup', () => { it('lists in the saved window order when the profile has no tab table (T7)', async () => { for (const sessionId of ['session-c', 'session-a', 'session-b']) { - await rig.chat(sessionId, { listed: false }) + await restTestChat(rig, sessionId, { listed: false }) } await rig.crash() const host = await rig.boot() From 855be2f8d619851481b4abb9d15f23b05327b371 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:54:31 -0700 Subject: [PATCH 08/21] fix(native-chat): start the chat history restore after the tab list has answered The restore that reads each chat's history was started, not awaited, from inside the listing call, so its synchronous start (about 110 ms on a 247-chat profile) ran on the listing's own stack and three chats opened before the list answered. It now starts on the next macrotask, after the answer has gone out. Also pins that listing writes nothing to the record store: tabs are projected from the store's own tab index, so a 247-tab list costs no per-tab transaction. --- ...tore-structured-agent-session-tabs-once.ts | 14 +++++-- ...runtime-structured-session-restore.test.ts | 9 ++++- ...-agent-session-startup-tab-restore.test.ts | 3 +- ...-agent-session-tab-listing-startup.test.ts | 37 +++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 36539e4484ce..66779e93c6c6 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -91,10 +91,16 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu for (const session of restored) { this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } - // After the tabs are out, so no chat's history holds the list or paint; each chat's status row - // arrives as its history opens. One chat's failure leaves the others and the list alone. - void host?.restoreReadableSessions(listedIds).catch((error: unknown) => { - console.warn('[structured-agent-session] restoring chat history after listing failed', error) + // After the list's answer has gone out: the pass's synchronous start would otherwise run on the + // listing's stack. Each chat's status row arrives as its history opens; one chat's failure + // leaves the others and the list alone. + setImmediate(() => { + void host?.restoreReadableSessions(listedIds).catch((error: unknown) => { + console.warn( + '[structured-agent-session] restoring chat history after listing failed', + error + ) + }) }) const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable // No host means no one can say which chats exist; with none on disk, empty is the answer. diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 529a2a9ce42b..f566f8944a74 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -83,6 +83,9 @@ describe('structured session cold restoration', () => { const second = runtime.restoreStructuredAgentSessionTabs() expect(second).toBe(first) await Promise.all([first, second]) + // The history pass starts after the list's answer has gone out, never on its stack. + expect(restoreReadableSessions).not.toHaveBeenCalled() + await new Promise((resolve) => setImmediate(resolve)) expect(hydrate).toHaveBeenCalledWith('workspace-1', { allowAttachedWindow: true, @@ -146,7 +149,9 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) + await vi.waitFor(() => + expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) + ) }) it('treats an empty durable visible-session index as authoritative', async () => { @@ -200,7 +205,7 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith([]) + await vi.waitFor(() => expect(restoreReadableSessions).toHaveBeenCalledWith([])) }) it('normalizes a restored tab id and removes it when closed', async () => { diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 79ad60a5486e..ec873be41502 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -217,8 +217,9 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str } } -/** The listing starts the history restore without awaiting it; this joins that pass. */ +/** The listing starts the history restore after its answer, without awaiting it; this joins it. */ async function historyRestored(): Promise { + await new Promise((resolve) => setImmediate(resolve)) await getStructuredAgentSessionHost()!.restoreReadableSessions() } diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts index 9c8687d524a0..4bf9f3952c58 100644 --- a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -12,6 +12,7 @@ import { restTestChat, type RestTestRig } from '../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { AgentSessionStoreTransactionQueue } from './agent-session-store-transaction-queue' import { OrcaRuntimeService } from './orca-runtime' import { RpcDispatcher } from './rpc/dispatcher' import { SESSION_TAB_METHODS } from './rpc/methods/session-tabs' @@ -45,6 +46,7 @@ afterEach(async () => { // The listing starts the lease check and the restore without awaiting either; both finish before // the rig removes their files. await rig.host.reconcileRestartLeases() + await new Promise((resolve) => setImmediate(resolve)) await rig.host.restoreReadableSessions() setStructuredAgentSessionHost(null) await rig.dispose() @@ -120,6 +122,40 @@ describe('listing chat tabs at startup', () => { expect(opened).toBe(3) }) + it('starts the history restore only after the list has answered', async () => { + for (const sessionId of ['session-1', 'session-2']) { + await restTestChat(rig, sessionId, { message: sessionId }) + } + await rig.crash() + await rig.boot() + const restore = vi.spyOn(rig.host, 'restoreReadableSessions') + const { listAll } = restartedRuntime() + + expect(await listAll()).toEqual(['session-1', 'session-2']) + // The caller has the answer before the pass has started, let alone opened a chat. + expect(restore).not.toHaveBeenCalled() + expect(rig.adapter.historyFilePath).not.toHaveBeenCalled() + await vi.waitFor(() => expect(rig.host.hasSession('session-2')).toBe(true)) + expect(restore).toHaveBeenCalledOnce() + }) + + it('writes nothing to the record store while it lists', async () => { + const ids = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5'] + for (const sessionId of ids) { + await restTestChat(rig, sessionId, { message: sessionId }) + } + await rig.crash() + await rig.boot() + // Settled first, so a write here could only be the listing's own. + await rig.host.reconcileRestartLeases() + const writes = vi.spyOn(AgentSessionStoreTransactionQueue.prototype, 'transact') + const { listAll } = restartedRuntime() + + expect(await listAll()).toEqual(ids) + // Every write, a tab's visibility included, is one store transaction. + expect(writes).not.toHaveBeenCalled() + }) + it('answers while the startup lease check never finishes (T2)', async () => { await restTestChat(rig, 'session-1', { message: 'one' }) await restTestChat(rig, 'session-2', { message: 'two' }) @@ -180,6 +216,7 @@ describe('listing chat tabs at startup', () => { const { listAll } = restartedRuntime() expect(await within(listAll())).toEqual(ids) + await vi.waitFor(() => expect(restore).toHaveBeenCalledOnce()) await restore.mock.results[0]?.value for (const sessionId of ['session-5', 'session-6']) { expect(rig.host.hasSession(sessionId)).toBe(true) From bf3aebe58ea0bc7aae1501b4057d6dc66f010fb7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:57:25 -0700 Subject: [PATCH 09/21] fix(native-chat): wait for the startup lease check again, and drop the per-write gate This PR had stopped startup waiting for the lease check (the pass that settles each chat's previous-run owner) and instead made every write to an unchecked chat wait for it. After #23964 that check can no longer fail startup, and a review run measured it at about 30 ms on a 247-chat Mac profile, so the gate bought nothing but a second mechanism. It also reordered two writes that arrived while a chat's lease flipped from unchecked to checked. Startup's structured step, the write path and worker release are back to the base's shape: startup awaits the non-throwing check, writes enter the chat's queue in arrival order, and an agent start still runs its own strict check before it acts. What stays is the listing from records and the tab table, and the chat history restore that starts after the list has answered. --- .../structured-agent-session-host.ts | 8 +- ...ctured-agent-session-refusal-retry.test.ts | 5 - ...uctured-agent-session-restart-reconcile.ts | 20 ---- ...ent-session-restored-status-parity.test.ts | 8 +- ...red-agent-session-write-lease-wait.test.ts | 111 ------------------ ...structured-agent-session-create-support.ts | 18 +-- ...runtime-structured-session-restore.test.ts | 2 +- .../worker/worker-release-completion.ts | 6 +- ...orker-release-structured-reconcile.test.ts | 62 ---------- .../structured-worker-tab-retirement.test.ts | 2 - ...ed-agent-session-startup-reconcile.test.ts | 11 +- ...-agent-session-startup-tab-restore.test.ts | 10 +- ...-agent-session-tab-listing-startup.test.ts | 46 +------- 13 files changed, 20 insertions(+), 289 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index f82b8a2cec15..b1ee31598231 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -10,10 +10,7 @@ import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { - createRestartReconciler, - serializeAfterLeaseCheck -} from './structured-agent-session-restart-reconcile' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' @@ -276,8 +273,7 @@ export class StructuredAgentSessionHost { publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, conversation: this.lifetime.conversation, - // Every write funnels through here: one to a chat with an unchecked lease waits for the check. - serialize: serializeAfterLeaseCheck(this.deps.store, this.reconcileLeases, this.serialize), + serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 7e0f530d179a..4c2db496c3cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -335,14 +335,9 @@ describe('agentSessionRefusalOperationState host oracle', () => { ...current, lease: { ...current.lease, unreconciled: true } })) - // A write runs the lease check first; only one that cannot settle leaves this refusal. - const stuck = vi - .spyOn(reconciling.store, 'reconcileOnRestart') - .mockRejectedValue(new Error('execution_owner_reconciling')) const spec = { method, operationId: operationId() } record( await assertHostAgreement(reconciling, spec, 'execution_owner_reconciling', async () => { - stuck.mockRestore() await setLease(reconciling, (current) => ({ ...current, lease: { ...current.lease, unreconciled: false } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index b5c6111220af..23f0e0c6bfae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -107,26 +107,6 @@ function failureKey(failure: unknown): string { return String(failure) } -/** The chat's queue, entered by a write to a chat whose lease is still the previous run's only - * after the lease check, which startup does not wait for. The write waits for that check (one pass - * over every unreconciled lease, not only this chat's), or runs it. With none owed it enqueues at - * once, keeping its place. A check that fails gates nothing: a conversation write (a send, a Stop) - * is still admitted, a provider-child write is refused while the lease stays unreconciled, and an - * agent start reconciles again and refuses. */ -export function serializeAfterLeaseCheck( - store: Pick, - reconcile: (sessionId: string) => Promise, - serialize: (sessionId: string, task: () => Promise) => Promise -): (sessionId: string, task: () => Promise) => Promise { - return (sessionId, task) => - store.getRecord(sessionId)?.lease.unreconciled - ? reconcile(sessionId).then( - () => serialize(sessionId, task), - () => serialize(sessionId, task) - ) - : serialize(sessionId, task) -} - async function reconcileCurrentLeases(deps: { store: AgentSessionRecordStore probe: (record: AgentSessionRecord) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts index ac399f72e8d8..333c1685051e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restored-status-parity.test.ts @@ -53,19 +53,17 @@ it('ends every chat where a direct read of it lands, and shows no pre-crash work const readRig = await createRestTestRig({}, { root: readRoot }) rigs.push(readRig) - // Startup's shape: the lease check started, the list answered, then the pass. + // Startup's shape: the lease check, the list, then the pass. const passHost = await passRig.boot() - const passCheck = passHost.reconcileRestartLeases() + await passHost.reconcileRestartLeases() await passHost.restoreReadableSessions(CORPUS) - await passCheck // The same run, where the window reads each chat instead. const readHost = readRig.host - const readCheck = readHost.reconcileRestartLeases() + await readHost.reconcileRestartLeases() for (const sessionId of CORPUS) { await readHost.history({ sessionId, direction: 'tail' }) } - await readCheck // `updatedAt` is when the row was projected, a wall-clock read that differs between two boots. const rows = (rig: RestTestRig): Record> => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts deleted file mode 100644 index 18bb1714d92b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-write-lease-wait.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Startup no longer waits for the lease check, so a write can reach a chat whose lease the previous -// app run left unadjudicated. Such a write waits for the lease check (or runs it) before it acts, -// so while that check succeeds none is admitted against the old lease. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' -import { hostTestOperationId } from './structured-agent-session-host-test-data' -import { - createRestTestRig, - REST_TEST_CALLER, - restTestChat, - sendRestTestMessage, - type RestTestRig -} from './structured-agent-session-rest-test-rig' - -let rig: RestTestRig -// A failed assertion must not leave teardown waiting on a held probe. -let releaseProbe = (): void => undefined - -beforeEach(async () => { - rig = await createRestTestRig() -}) - -afterEach(async () => { - releaseProbe() - await rig.dispose() - closeTestJournalHostDatabases() - vi.restoreAllMocks() -}) - -/** Boots over a crashed run and starts the startup lease check with its owner probe held. */ -async function bootWithSlowLeaseCheck() { - await restTestChat(rig, 'session-a', { message: 'before the restart' }) - await rig.crash() - const probe = Promise.withResolvers() - const probing = Promise.withResolvers() - releaseProbe = probe.resolve - rig.probeOwner.mockImplementation(async () => { - probing.resolve() - await probe.promise - return { outcome: 'pid-absent' } - }) - const host = await rig.boot() - // What startup does now: start the check, and wait for nothing. - const startupCheck = host.reconcileRestartLeases() - await probing.promise - // Every admission, with the lease it was admitted against. - const admittedUnreconciled: boolean[] = [] - const admit = rig.store.admitMutationOperation - vi.spyOn(rig.store, 'admitMutationOperation').mockImplementation(async (args) => { - const admitted = await admit(args) - admittedUnreconciled.push(admitted?.record.lease.unreconciled ?? true) - return admitted - }) - return { host, probe, startupCheck, admittedUnreconciled } -} - -/** Long enough for an unheld write to open its chat and be admitted. */ -function settleTasks(): Promise { - return new Promise((resolve) => setTimeout(resolve, 200)) -} - -describe('a write during a slow startup lease check', () => { - it('lands a send after the chat’s check, and starts one agent (REQUIRED)', async () => { - const { probe, startupCheck, admittedUnreconciled } = await bootWithSlowLeaseCheck() - - let answered = false - const sent = sendRestTestMessage(rig, 'session-a', 'after the restart').then((result) => { - answered = true - return result - }) - await settleTasks() - expect(answered).toBe(false) - expect(admittedUnreconciled).toEqual([]) - - probe.resolve() - await expect(sent).resolves.toMatchObject({ ok: true }) - await startupCheck - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - - expect(admittedUnreconciled).toEqual([false]) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() - }) - - it('holds a Stop until the chat’s check has run', async () => { - const { host, probe, startupCheck, admittedUnreconciled } = await bootWithSlowLeaseCheck() - - const fields = {} - const stopped = host.cancel(REST_TEST_CALLER, { - envelope: { - sessionId: 'session-a', - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: 'session-a', - fields - }) - } - }) - await settleTasks() - expect(admittedUnreconciled).toEqual([]) - - probe.resolve() - await expect(stopped).resolves.toMatchObject({ ok: true }) - await startupCheck - expect(admittedUnreconciled).toEqual([false]) - expect(rig.adapter.acquire).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index af7c4aa80d67..c0d4c596ffae 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -252,27 +252,16 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR return this.structuredAgentSessionTabRestorePromise } - /** Startup's structured step: the host and PTY inventory, awaited; the lease check, started and - * never awaited. Neither the tab list nor paint needs leases, and a write to a chat whose lease - * is unchecked first waits for that check (`serializeAfterLeaseCheck`). */ - async prepareStructuredAgentSessionStartupRestoration(): Promise { - await this.ensureStructuredAgentSessionInventory() - if (this.hasPersistedStructuredAgentSessionStore()) { - // Never rejects: a failure goes to the host's lease-reconcile report. - void getStructuredAgentSessionHost()?.reconcileRestartLeases() - } - } - - protected ensureStructuredAgentSessionInventory(): Promise { + prepareStructuredAgentSessionStartupRestoration(): Promise { this.structuredAgentSessionStartupRestorePromise ??= - this.ensureStructuredAgentSessionInventoryOnce().catch((error) => { + this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { this.structuredAgentSessionStartupRestorePromise = null throw error }) return this.structuredAgentSessionStartupRestorePromise } - private async ensureStructuredAgentSessionInventoryOnce(): Promise { + protected async prepareStructuredAgentSessionStartupRestorationOnce(): Promise { if (!this.hasPersistedStructuredAgentSessionStore()) { return } @@ -282,6 +271,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR this.ensureStructuredAgentSessionHost() ) await this.refreshMobileSessionPtyRecords() + await getStructuredAgentSessionHost()?.reconcileRestartLeases() } protected hasPersistedStructuredAgentSessionStore(): boolean { diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index f566f8944a74..4c116e13416d 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -51,7 +51,7 @@ describe('structured session cold restoration', () => { expect(restoreReadableSessions).not.toHaveBeenCalled() }) - it('loads records, inventories PTYs, starts the lease check, projects tabs, then restores history, once', async () => { + it('loads records, inventories PTYs, restores ownership, projects tabs, then restores history, once', async () => { const runtime = new OrcaRuntimeService() const hydrate = vi.fn() const refresh = vi.fn(async () => new Set()) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts index 09ebcdf3e617..4aca4d0420a0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts @@ -19,7 +19,6 @@ import { workerTerminalLeaseIsCurrent } from './worker-terminal-release-lease' import { resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' import { stopStructuredWorkerForRelease } from './structured-worker-release-stop' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' -import { getStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' export { archiveSummary, @@ -96,9 +95,7 @@ async function completeWorkerTerminalReleaseOnce( if (isStructuredWorkerHandle(resource.terminal_handle)) { // Observation and archive capture both read the structured host, and after a restart nothing // has installed it yet — the startup recovery reconciler runs exactly this path. Installing it - // here is what lets the release see the session instead of reporting it unreadable. Startup no - // longer waits for the lease check either, and an unchecked worker lease from the previous run - // reads as live, so the release runs that check first (it never rejects; failures are reported). + // here is what lets the release see the session instead of reporting it unreadable. // // NOT yet handled, and deliberately follow-up: rebinding a restarted runtime to a structured // worker's redrive subscription. Until that exists, a worker that survives a restart has its @@ -110,7 +107,6 @@ async function completeWorkerTerminalReleaseOnce( error ) }) - await getStructuredAgentSessionHost()?.reconcileRestartLeases() } const worker = db.getWorkerDispatch(dispatchId) if (!worker || worker.agent_terminal_handle !== resource.terminal_handle) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts deleted file mode 100644 index 7972b485f7f1..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// After a restart the release is the first thing to read a structured worker, and startup no longer -// waits for the lease check. The release runs that check before it observes the worker (T6). - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { setStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { mintStructuredWorkerHandle } from '../../../../structured-worker-identity' - -const { order } = vi.hoisted(() => { - const order: string[] = [] - return { order } -}) - -vi.mock('./worker-observation', () => ({ - inspectWorkerTerminal: vi.fn(async () => { - order.push('observe') - return { status: 'identity_changed' } - }) -})) - -const { completeWorkerTerminalRelease } = await import('./worker-release-completion') - -afterEach(() => { - order.length = 0 - setStructuredAgentSessionHost(null) -}) - -describe('structured worker release after a restart', () => { - it('reads the worker only once the restart reconcile has settled its lease', async () => { - const handle = mintStructuredWorkerHandle() - const runtime = { - ensureStructuredAgentSessionHost: vi.fn(async () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release reads only the reconcile stubbed here. - setStructuredAgentSessionHost({ - reconcileRestartLeases: async () => { - // A reconcile that probes processes settles after a macrotask, not a microtask. - await new Promise((resolve) => setTimeout(resolve, 0)) - order.push('reconcile') - } - } as never) - }) - } - const db = { - getWorkerDispatch: () => ({ agent_terminal_handle: handle }), - revertWorkerTerminalReleaseToRetained: () => ({ archive_source: null, archive_status: null }), - recordWorkerTerminalRecoveryAttempt: vi.fn() - } - - const receipt = await completeWorkerTerminalRelease({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this path reads only the runtime member stubbed above. - runtime: runtime as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retained path reads only the dispatch rows stubbed above. - db: db as never, - dispatchId: 'dispatch-1', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retained path reads only the id and handle. - resource: { id: 'resource-1', terminal_handle: handle } as never, - mode: 'recovery' - }) - - expect(receipt.state).toBe('retained') - expect(order).toEqual(['reconcile', 'observe']) - }) -}) diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts index 0945d32834f0..b48dccf52a39 100644 --- a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -43,11 +43,9 @@ function installHost(options: { closeThrows?: boolean; lease?: Record undefined, hasSession: () => attached, hold: async () => {}, release: () => {}, diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index a759341ab2d8..33b26f7569bb 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -83,8 +83,7 @@ it('finishes startup over records a newer Orca wrote, reports it, and writes not // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - // Startup does not wait for the check; its failure is reported when it lands. - await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledOnce() expect(onError).toHaveBeenCalledWith({ scope: 'structured-agent-session-lease-reconcile', error: expect.objectContaining({ message: 'agent_session_legacy_required' }) @@ -104,10 +103,8 @@ it('logs the failure when the host has no error sink', async () => { startupRuntime().prepareStructuredAgentSessionStartupRestoration() ).resolves.toBeUndefined() - await vi.waitFor(() => - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] reconciling chat leases failed', - expect.objectContaining({ message: 'agent_session_legacy_required' }) - ) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] reconciling chat leases failed', + expect.objectContaining({ message: 'agent_session_legacy_required' }) ) }) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index dae8a776e195..50d23f0d09d7 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -297,15 +297,13 @@ describe('restoring the chat tabs open at quit', () => { await seedStore(records, { visible: chats }) await seedHistory(records) vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, onError, published } = startupRuntime({ + const { runtime, published } = startupRuntime({ afterInstall: () => { lock.failing = true } }) await runtime.prepareStructuredAgentSessionStartupRestoration() - // Startup starts the lease check without awaiting it; its report is where it lands. - await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) const prepared = lock.refused await runtime.restoreStructuredAgentSessionTabs() await historyRestored() @@ -329,8 +327,6 @@ describe('restoring the chat tabs open at quit', () => { const writes = spyOnTabWrites() const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() - // Joins startup's un-awaited lease check, so it takes no lock grant meant for the seed. - await getStructuredAgentSessionHost()!.reconcileRestartLeases() // One more take, then held: a seed written chat by chat would stop part way. lock.grants = 1 @@ -350,7 +346,7 @@ describe('restoring the chat tabs open at quit', () => { await seedStore(records) await seedHistory(records) vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, onError, published } = startupRuntime({ + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B], afterInstall: () => { lock.failing = true @@ -358,7 +354,6 @@ describe('restoring the chat tabs open at quit', () => { }) await runtime.prepareStructuredAgentSessionStartupRestoration() - await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) const prepared = lock.refused await runtime.restoreStructuredAgentSessionTabs() await historyRestored() @@ -376,7 +371,6 @@ describe('restoring the chat tabs open at quit', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() - await getStructuredAgentSessionHost()!.reconcileRestartLeases() lock.failing = true await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts index 4bf9f3952c58..078be7f491a0 100644 --- a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -1,5 +1,5 @@ // The chat tab list a client asks for at startup answers from records and the tab table. It waits -// for no chat's history and for no lease check; each chat's history opens after the answer. +// for no chat's history; each chat's history opens after the answer. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' @@ -43,9 +43,8 @@ beforeEach(async () => { afterEach(async () => { releaseHeld() - // The listing starts the lease check and the restore without awaiting either; both finish before - // the rig removes their files. - await rig.host.reconcileRestartLeases() + // The listing starts the history restore after its answer, without awaiting it; it finishes + // before the rig removes its files. await new Promise((resolve) => setImmediate(resolve)) await rig.host.restoreReadableSessions() setStructuredAgentSessionHost(null) @@ -156,45 +155,6 @@ describe('listing chat tabs at startup', () => { expect(writes).not.toHaveBeenCalled() }) - it('answers while the startup lease check never finishes (T2)', async () => { - await restTestChat(rig, 'session-1', { message: 'one' }) - await restTestChat(rig, 'session-2', { message: 'two' }) - await rig.crash() - const probe = Promise.withResolvers() - releaseHeld = probe.resolve - rig.probeOwner.mockImplementation(async () => { - await probe.promise - return { outcome: 'pid-absent' } - }) - await rig.boot() - const { runtime, listAll } = restartedRuntime() - - // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. - expect(await within(runtime.prepareStructuredAgentSessionStartupRestoration())).toBe(undefined) - expect(await within(listAll())).toEqual(['session-1', 'session-2']) - expect(rig.probeOwner).toHaveBeenCalled() - probe.resolve() - await rig.host.reconcileRestartLeases() - }) - - it('answers when the startup lease check fails (T2)', async () => { - await restTestChat(rig, 'session-1', { message: 'one' }) - await rig.crash() - rig.probeOwner.mockRejectedValue(new Error('probe unavailable')) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const onLeaseReconcileFailure = vi.fn() - await rig.boot({ onLeaseReconcileFailure }) - const { runtime, listAll } = restartedRuntime() - - await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBe(undefined) - expect(await within(listAll())).toEqual(['session-1']) - await vi.waitFor(() => - expect(onLeaseReconcileFailure).toHaveBeenCalledWith( - expect.objectContaining({ message: 'probe unavailable' }) - ) - ) - }) - it('lists every chat when four fail to open, and the others still get status rows (T5)', async () => { // More chats than the restore opens at once, with the first four failing: each failure must // cost only its own chat, not one of the restore's four lanes. From ebdc5b92a010af0e05e9d84f3c8771fecd0f1ef9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:18:24 -0700 Subject: [PATCH 10/21] fix(native-chat): start the chat history restore once the tab list's answer is out The history restore was deferred with setImmediate from inside the tab restore, on the theory that the list answer would be sent by then. It is not: after the tab restore, session.tabs.listAll and the subscribe streams still run the PTY census (daemon, git and SSH calls), and the deferred restore fired during it. Four or five chats opened before the list answered, which is what the deferral was meant to prevent. The tab restore now only records the history restore it owes. Every session.tabs handler that restores tabs (list, listAll, subscribe, subscribeAll) goes through one wrapper that starts the owed restore when that handler's answer has settled: sent, failed or abandoned. The first to settle starts it; the rest find it started. The tab restore itself is no longer callable from a handler without that wrapper. Also: both quit checks in the restore now have a test (one skips the lease bookkeeping, one the open), and a comment no longer says a chat with missing history explains itself; it reads empty. --- .../structured-agent-session-host-tabs.ts | 4 +- ...gent-session-restore-after-listing.test.ts | 30 +++++- ...tore-structured-agent-session-tabs-once.ts | 32 +++--- src/main/runtime/orca-runtime-runtime-id.ts | 3 + ...runtime-structured-session-restore.test.ts | 7 +- .../rpc/methods/session-tabs-inventory.ts | 39 +++----- src/main/runtime/rpc/methods/session-tabs.ts | 99 ++++++++++--------- .../methods/structured-session-tab-restore.ts | 21 +++- ...-agent-session-startup-tab-restore.test.ts | 12 ++- ...-agent-session-tab-listing-startup.test.ts | 73 ++++++++++---- 10 files changed, 209 insertions(+), 111 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 0facc7cb037e..61d3d353fe23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -48,8 +48,8 @@ export type StructuredAgentSessionTab = { } /** Tabs from durable state alone: each requested id, once, in the order given, that has a record - * this host serves. Opens nothing: a chat whose history is missing or unreadable keeps its tab and - * explains itself when read. */ + * this host serves. Opens nothing: a chat whose history is unreadable keeps its tab and its read + * says why; one whose history is missing keeps its tab and reads empty. */ export function listPersistedSessionTabs( deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts index 9752089002b9..7fe8dac70a21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts @@ -68,8 +68,35 @@ describe('the restore pass after the tab list', () => { expect(host.hasSession('session-1')).toBe(true) }) - it('opens nothing more once the host is quitting', async () => { + it('does not open a chat it reached before quit began but opens after', async () => { + await restTestChat(rig, LAST, { message: 'asked' }) + await rig.crash() + const host = await rig.boot() + const { runtimeState, lifetime } = host.collaboratorsForTests() + // Holds the chat between the pass reaching it and its open. + const settling = Promise.withResolvers() + releaseHeld = settling.resolve + const resolveRecovery = runtimeState.resolveRecovery.bind(runtimeState) + const recovery = vi.spyOn(runtimeState, 'resolveRecovery').mockImplementation(async (id) => { + await settling.promise + return resolveRecovery(id) + }) + const pass = host.restoreReadableSessions([LAST]) + await vi.waitFor(() => expect(recovery).toHaveBeenCalled()) + + const teardown = host.flushAllStreamedEvents() + await vi.waitFor(() => expect(lifetime.isDisposed()).toBe(true)) + settling.resolve() + await teardown + await pass + + expect(restTestOpens(rig, LAST)).toBe(0) + expect(host.hasSession(LAST)).toBe(false) + }) + + it('opens nothing more, and settles no more leases, once the host is quitting', async () => { const { host, pass, release } = await bootWithHeldPass() + const recovery = vi.spyOn(host.collaboratorsForTests().runtimeState, 'resolveRecovery') const teardown = host.flushAllStreamedEvents() release() @@ -78,5 +105,6 @@ describe('the restore pass after the tab list', () => { expect(restTestOpens(rig, LAST)).toBe(0) expect(host.hasSession(LAST)).toBe(false) + expect(recovery).not.toHaveBeenCalledWith(LAST) }) }) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index ef33e0316db6..ef11b8717651 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -91,17 +91,17 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu for (const session of restored) { this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } - // After the list's answer has gone out: the pass's synchronous start would otherwise run on the - // listing's stack. Each chat's status row arrives as its history opens; one chat's failure - // leaves the others and the list alone. - setImmediate(() => { - void host?.restoreReadableSessions(listedIds).catch((error: unknown) => { - console.warn( - '[structured-agent-session] restoring chat history after listing failed', - error - ) - }) - }) + // Each chat's status row arrives as its history opens; one chat's failure leaves the others and + // the list alone. Whoever answers with this list starts it, once that answer is out. + this.owedStructuredAgentSessionHistoryRestore = host + ? () => + void host.restoreReadableSessions(listedIds).catch((error: unknown) => { + console.warn( + '[structured-agent-session] restoring chat history after listing failed', + error + ) + }) + : null const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable // No host means no one can say which chats exist; with none on disk, empty is the answer. this.structuredAgentSessionInventoryUnverifiable = @@ -112,6 +112,16 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } } + /** Starts the history restore the tab restore owes, once. On the next macrotask, so a caller that + * starts it as it answers has sent that answer first. */ + startStructuredAgentSessionHistoryRestore(): void { + const owed = this.owedStructuredAgentSessionHistoryRestore + this.owedStructuredAgentSessionHistoryRestore = null + if (owed) { + setImmediate(owed) + } + } + async publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index f033cb456ebc..1ff91e49b43b 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -121,6 +121,9 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionTabRestorePromise: Promise | null = null + // The history restore a tab restore owes, until a caller that answered with its list starts it. + protected owedStructuredAgentSessionHistoryRestore: (() => void) | null = null + // Whether the last tab restore ran with chats on disk but no host to list them. protected structuredAgentSessionInventoryUnverifiable = false diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 4c116e13416d..880cf09e68cf 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -83,7 +83,10 @@ describe('structured session cold restoration', () => { const second = runtime.restoreStructuredAgentSessionTabs() expect(second).toBe(first) await Promise.all([first, second]) - // The history pass starts after the list's answer has gone out, never on its stack. + // The history pass is owed, not started: the caller that answers with the list starts it. + expect(restoreReadableSessions).not.toHaveBeenCalled() + runtime.startStructuredAgentSessionHistoryRestore() + runtime.startStructuredAgentSessionHistoryRestore() expect(restoreReadableSessions).not.toHaveBeenCalled() await new Promise((resolve) => setImmediate(resolve)) @@ -148,6 +151,7 @@ describe('structured session cold restoration', () => { } as never) await runtime.restoreStructuredAgentSessionTabs() + runtime.startStructuredAgentSessionHistoryRestore() await vi.waitFor(() => expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) @@ -204,6 +208,7 @@ describe('structured session cold restoration', () => { } as never) await runtime.restoreStructuredAgentSessionTabs() + runtime.startStructuredAgentSessionHistoryRestore() await vi.waitFor(() => expect(restoreReadableSessions).toHaveBeenCalledWith([])) }) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index 3ab06a298fc6..5ed2ee8d902b 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -6,7 +6,7 @@ import { projectSessionTabAgentStatus } from './session-tab-agent-status-project import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' -import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { answerAfterStructuredTabRestore } from './structured-session-tab-restore' type SessionTabsInventory = { snapshots: RuntimeMobileSessionTabsResult[] @@ -242,37 +242,30 @@ export async function subscribeSessionTabsInventory( } let collected: Awaited> | undefined try { - // Why: restore after registering, so an unsubscribe or socket close while it runs still finds the stream. - const restoring = restoreStructuredTabsIfSupported(context) - if (restoring) { - await restoring - if (closed) { - return - } - } - for (let attempt = 1; !collected; attempt += 1) { - censusInvalidated = false - const candidate = await collectSessionTabsInventory( - { ...context, signal: inventoryController.signal }, - true - ) - if (closed) { - return - } - if (censusInvalidated) { + // Why: restore after registering, so an unsubscribe or socket close while it runs still finds the + // stream. The snapshots emit right after this settles, before the history restore it starts. + collected = await answerAfterStructuredTabRestore(context, async () => { + for (let attempt = 1; !closed; attempt += 1) { + censusInvalidated = false + const candidate = await collectSessionTabsInventory( + { ...context, signal: inventoryController.signal }, + true + ) + if (!censusInvalidated || closed) { + return candidate + } clearBufferedChanges() if (attempt === MAX_CENSUS_COLLECTION_ATTEMPTS) { throw new Error('session_tabs_inventory_unstable') } - } else { - collected = candidate } - } + return undefined + }) } catch (error) { runtime.cleanupSubscription(subscriptionId) throw error } - if (closed) { + if (closed || !collected) { return } const { inventory, changeSequence } = collected diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 8d3d1af33bf2..e3199ad0e12e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -15,7 +15,7 @@ import { import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' -import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { answerAfterStructuredTabRestore } from './structured-session-tab-restore' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' @@ -25,23 +25,21 @@ export const SESSION_TAB_METHODS = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { - await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) - return projectSessionTabsForClient( - await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), - clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) + handler: (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => + answerAfterStructuredTabRestore({ runtime, clientKind, clientCapabilities }, async () => + projectSessionTabsForClient( + await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), + clientKind, + clientCapabilities, + isStructuredNativeChatEnabled(runtime) + ) ) - } }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, context) => { - await restoreStructuredTabsIfSupported(context) - return listSessionTabsInventory(context) - } + handler: (_params, context) => + answerAfterStructuredTabRestore(context, () => listSessionTabsInventory(context)) }), ...SESSION_TAB_MUTATION_METHODS, ...SESSION_TAB_CLOSE_METHODS, @@ -144,51 +142,56 @@ export const SESSION_TAB_METHODS = [ register(explicitWorktreeId) } try { - await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) - if (released) { - return - } - const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) - if (released) { - return - } - if (!subscriptionId) { - register(initial.worktree) - if (released) { - return - } - } - const subscribedWorktree = initial.worktree - const withProofDelta = createSessionTabsRetirementProofDelta(clientCapabilities) - emit({ - type: 'snapshot', - ...withProofDelta( - projectSessionTabsForClient( - initial, - clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) - ) - ) - }) - if (released) { - return - } - stopListening = runtime.onMobileSessionTabsChanged((snapshot) => { - if (snapshot.worktree === subscribedWorktree) { + // The first snapshot emits inside this, before the history restore it starts. + await answerAfterStructuredTabRestore( + { runtime, clientKind, clientCapabilities }, + async () => { + if (released) { + return + } + const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) + if (released) { + return + } + if (!subscriptionId) { + register(initial.worktree) + if (released) { + return + } + } + const subscribedWorktree = initial.worktree + const withProofDelta = createSessionTabsRetirementProofDelta(clientCapabilities) emit({ - type: 'updated', + type: 'snapshot', ...withProofDelta( projectSessionTabsForClient( - snapshot, + initial, clientKind, clientCapabilities, isStructuredNativeChatEnabled(runtime) ) ) }) + if (released) { + return + } + stopListening = runtime.onMobileSessionTabsChanged((snapshot) => { + if (snapshot.worktree === subscribedWorktree) { + emit({ + type: 'updated', + ...withProofDelta( + projectSessionTabsForClient( + snapshot, + clientKind, + clientCapabilities, + isStructuredNativeChatEnabled(runtime) + ) + ) + }) + } + }, pairedDeviceId) } - }, pairedDeviceId) + ) } catch (error) { // A stream already ended by its release must not also report an error. if (released) { diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index 6ec962414229..06c79eb20f7a 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -11,7 +11,7 @@ import { * project after a desktop restart — no chat and no prompt. The setting still gates it, because * with structured chat off there is nothing for any mobile client to reach. Restoring spawns no * provider child for a cleanly closed session. */ -export function restoreStructuredTabsIfSupported( +function restoreStructuredTabsIfSupported( context: Pick ): Promise | undefined { const shouldRestore = @@ -24,3 +24,22 @@ export function restoreStructuredTabsIfSupported( // Nothing to restore: callers skip the await, so a stream's setup keeps its timing. return undefined } + +/** The tab restore, then the caller's answer. The chat history restore the tab restore owes starts + * only once that answer has settled (sent, failed or abandoned), so the answer's own work, such as + * the PTY census, never waits behind it. The first caller to settle starts it. */ +export async function answerAfterStructuredTabRestore( + context: Pick, + answer: () => Promise +): Promise { + const restoring = restoreStructuredTabsIfSupported(context) + if (!restoring) { + return answer() + } + try { + await restoring + return await answer() + } finally { + context.runtime.startStructuredAgentSessionHistoryRestore?.() + } +} diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 50d23f0d09d7..38e127dcf845 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -217,8 +217,10 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str } } -/** The listing starts the history restore after its answer, without awaiting it; this joins it. */ -async function historyRestored(): Promise { +/** Starts the history restore the listing owes, as the caller that answers with it does, and joins + * it. */ +async function historyRestored(runtime: OrcaRuntimeService): Promise { + runtime.startStructuredAgentSessionHistoryRestore() await new Promise((resolve) => setImmediate(resolve)) await getStructuredAgentSessionHost()!.restoreReadableSessions() } @@ -263,7 +265,7 @@ describe('restoring the chat tabs open at quit', () => { }) await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() - await historyRestored() + await historyRestored(runtime) expect(published().map((tab) => tab.id)).toEqual([ `agent-session:${CHAT_A}`, @@ -306,7 +308,7 @@ describe('restoring the chat tabs open at quit', () => { await runtime.prepareStructuredAgentSessionStartupRestoration() const prepared = lock.refused await runtime.restoreStructuredAgentSessionTabs() - await historyRestored() + await historyRestored(runtime) expect(published()).toHaveLength(count) expect(prepared).toBe(1) @@ -356,7 +358,7 @@ describe('restoring the chat tabs open at quit', () => { await runtime.prepareStructuredAgentSessionStartupRestoration() const prepared = lock.refused await runtime.restoreStructuredAgentSessionTabs() - await historyRestored() + await historyRestored(runtime) expect(published()).toHaveLength(2) expect(prepared).toBe(1) diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts index 078be7f491a0..2823e29a618d 100644 --- a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -25,6 +25,7 @@ type ListingInternals = { getKnownWorkspaceSessionWorktreeIds(): Set hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set refreshMobileSessionPtyRecords(): Promise | null> + refreshMobileSessionPtyInventory(): Promise ensureStructuredAgentSessionHost(): Promise } @@ -66,6 +67,11 @@ function restartedRuntime(workspaceSession: unknown = null) { internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() + // The census an answer takes after the tab restore: daemon, git and SSH round trips in the app. + internal.refreshMobileSessionPtyInventory = async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return null + } internal.ensureStructuredAgentSessionHost = async () => { setStructuredAgentSessionHost(rig.host) } @@ -80,11 +86,31 @@ function restartedRuntime(workspaceSession: unknown = null) { } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: listAll answers with the inventory shape its handler returns. const { snapshots } = response.result as { snapshots: RuntimeMobileSessionTabsResult[] } - return snapshots.flatMap((snapshot) => - snapshot.tabs.flatMap((tab) => (tab.type === 'agent-session' ? [tab.sessionId] : [])) - ) + return chatIds(snapshots) } - return { runtime, listAll } + /** Mobile's stream: resolves with its first snapshots, and what had run when they were sent. */ + const subscribeAll = (seen: () => unknown) => + new Promise<{ ids: string[]; atAnswer: unknown }>((resolve, reject) => { + void dispatcher.dispatchStreaming( + { id: 'subscribe-all', authToken: 'tok', method: 'session.tabs.subscribeAll' }, + (message) => { + const frame = JSON.parse(message) + if (!frame.ok) { + reject(new Error(JSON.stringify(frame.error))) + } else if (frame.result?.type === 'snapshots') { + resolve({ ids: chatIds(frame.result.snapshots), atAnswer: seen() }) + } + }, + { ...CONTEXT, connectionId: 'connection-1' } + ) + }) + return { runtime, listAll, subscribeAll } +} + +function chatIds(snapshots: RuntimeMobileSessionTabsResult[]): string[] { + return snapshots.flatMap((snapshot) => + snapshot.tabs.flatMap((tab) => (tab.type === 'agent-session' ? [tab.sessionId] : [])) + ) } /** Resolves with the answer, or with 'still waiting' once `ms` pass without one. */ @@ -121,22 +147,31 @@ describe('listing chat tabs at startup', () => { expect(opened).toBe(3) }) - it('starts the history restore only after the list has answered', async () => { - for (const sessionId of ['session-1', 'session-2']) { - await restTestChat(rig, sessionId, { message: sessionId }) - } - await rig.crash() - await rig.boot() - const restore = vi.spyOn(rig.host, 'restoreReadableSessions') - const { listAll } = restartedRuntime() + it.each(['listAll', 'subscribeAll'] as const)( + 'starts the history restore only after %s has answered, census and all', + async (method) => { + const ids = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5'] + for (const sessionId of ids) { + await restTestChat(rig, sessionId, { message: sessionId }) + } + await rig.crash() + await rig.boot() + const restore = vi.spyOn(rig.host, 'restoreReadableSessions') + const { runtime, listAll, subscribeAll } = restartedRuntime() + const seen = () => ({ + started: restore.mock.calls.length, + opens: rig.adapter.historyFilePath.mock.calls.length + }) - expect(await listAll()).toEqual(['session-1', 'session-2']) - // The caller has the answer before the pass has started, let alone opened a chat. - expect(restore).not.toHaveBeenCalled() - expect(rig.adapter.historyFilePath).not.toHaveBeenCalled() - await vi.waitFor(() => expect(rig.host.hasSession('session-2')).toBe(true)) - expect(restore).toHaveBeenCalledOnce() - }) + const answered = + method === 'listAll' ? { ids: await listAll(), atAnswer: seen() } : await subscribeAll(seen) + + expect(answered).toEqual({ ids, atAnswer: { started: 0, opens: 0 } }) + await vi.waitFor(() => ids.forEach((id) => expect(rig.host.hasSession(id)).toBe(true))) + expect(restore).toHaveBeenCalledOnce() + runtime.cleanupSubscriptionsForConnection('connection-1') + } + ) it('writes nothing to the record store while it lists', async () => { const ids = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5'] From c3af299b326e7a9619365c1b9c151b9027b635ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:46:00 -0700 Subject: [PATCH 11/21] test(native-chat): give the rest rig's and T3's dispatch waits a 10 s timeout Both waited on the provider dispatch with vi.waitFor's default 1 s, and T3's wait failed once under load. Neighbouring structured-session tests wait 10 s for the same kind of assertion. --- .../structured-agent-session-persisted-tab-listing.test.ts | 2 +- .../structured-agent-session-rest-test-rig.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts index b75ab410111e..13fd635be312 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-persisted-tab-listing.test.ts @@ -65,7 +65,7 @@ describe('the tab list after a restart', () => { expect(page.ok && page.page.items).toEqual([]) const sent = await sendRestTestMessage(rig, 'session-new', 'first words') expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), { timeout: 10_000 }) const after = await host.history({ sessionId: 'session-new', direction: 'tail' }) expect(JSON.stringify(after.ok && after.page)).toContain('first words') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 6ae31f023cf0..1f5fdad403d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -283,7 +283,8 @@ export async function restTestChat( if (options.message === undefined) { return } - const dispatched = rig.adapter.dispatch.mock.calls.length + const { dispatch } = rig.adapter + const dispatched = dispatch.mock.calls.length const sent = await rig.host.send( REST_TEST_CALLER, restTestSend(options.message, attached.fence, sessionId) @@ -291,7 +292,9 @@ export async function restTestChat( if (!sent.ok) { throw new Error(`send refused: ${sent.refusal.code}`) } - await vi.waitFor(() => expect(rig.adapter.dispatch.mock.calls.length).toBeGreaterThan(dispatched)) + await vi.waitFor(() => expect(dispatch.mock.calls.length).toBeGreaterThan(dispatched), { + timeout: 10_000 + }) } /** Creates the rig's own chat, lists its tab, and sends one message so its journal is on disk. */ From 0cbeb1441ffc451af9744c1691d40b7b84b7cd76 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:55:28 -0700 Subject: [PATCH 12/21] fix(native-chat): derive /clear replacements once per tab listing, not once per tab Every tab snapshot the runtime stores or answers re-applies the /clear replacements, and deriving them walks every chat record. The listing stores one snapshot per restored tab and answers one per worktree, so a 247-tab, 1,301-record profile derived them 289 times per listAll. On main those derivations were split up by the per-tab store writes this PR removed, so here they ran as one long main-process task (about 1.9 s on the Windows measurement machine). Each session.tabs listing request, tab restore included, now derives them at most once, on first use, and every snapshot it stores or answers reuses that value. Anything outside a listing, and any timer or task a listing leaves running after it answers, still derives them fresh. A /clear committed while a listing waits on I/O is still shown: its own commit re-projects the stored snapshot. --- ...e-prune-mobile-session-tab-group-layout.ts | 4 +- ...tore-structured-agent-session-tabs-once.ts | 3 +- src/main/runtime/orca-runtime-runtime-id.ts | 4 +- .../methods/structured-session-tab-restore.ts | 21 +++++--- ...-agent-session-tab-listing-startup.test.ts | 45 ++++++++++++++++ ...ed-conversation-replacements-scope.test.ts | 54 +++++++++++++++++++ ...uctured-conversation-replacements-scope.ts | 33 ++++++++++++ 7 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 src/main/runtime/structured-conversation-replacements-scope.test.ts create mode 100644 src/main/runtime/structured-conversation-replacements-scope.ts diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 75005726c2db..f59ce30e1aa2 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -23,8 +23,8 @@ import type { import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' +import { currentConversationReplacements } from './structured-conversation-replacements-scope' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { structuredWorkerAgentStatus } from './orchestration/structured-worker-group-addressing' @@ -78,7 +78,7 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime protected toMobileSessionTabsResult( snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsResult { - for (const replacement of getStructuredAgentSessionHost()?.conversationReplacements?.() ?? []) { + for (const replacement of currentConversationReplacements()) { snapshot = replaceConversationInSnapshot(snapshot, replacement) } return projectRuntimeMobileSessionTabs(snapshot, this.getMobileSessionProjectionHost()) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index ef11b8717651..c075ef76863e 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -6,6 +6,7 @@ import { replaceConversationInSnapshot } from './structured-conversation-tab-rep import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' +import { currentConversationReplacements } from './structured-conversation-replacements-scope' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionAgentTab, @@ -85,7 +86,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu restored.map((session) => session.sessionId) ) // Past the seed, projecting records nothing. - for (const replacement of host?.conversationReplacements?.() ?? []) { + for (const replacement of currentConversationReplacements()) { this.replaceStructuredAgentSessionTab(replacement) } for (const session of restored) { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 1ff91e49b43b..515b2ead6167 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -1,8 +1,8 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { randomUUID } from 'node:crypto' import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' +import { currentConversationReplacements } from './structured-conversation-replacements-scope' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeClientSettingsController } from './runtime-client-settings' @@ -105,7 +105,7 @@ export class OrcaRuntimeWithRuntimeId { worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot { - for (const replacement of getStructuredAgentSessionHost()?.conversationReplacements?.() ?? []) { + for (const replacement of currentConversationReplacements()) { snapshot = replaceConversationInSnapshot(snapshot, replacement) } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index 06c79eb20f7a..0afba5215883 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,4 +1,5 @@ import type { RpcContext } from '../core' +import { withListingConversationReplacements } from '../../structured-conversation-replacements-scope' import { isStructuredNativeChatEnabled, supportsStructuredAgentSessions @@ -32,14 +33,20 @@ export async function answerAfterStructuredTabRestore( context: Pick, answer: () => Promise ): Promise { - const restoring = restoreStructuredTabsIfSupported(context) - if (!restoring) { - return answer() - } + let restoring: Promise | undefined try { - await restoring - return await answer() + // One listing, tab restore included, so the /clear replacements every snapshot it stores or + // answers applies are derived once for it, not once per tab and per worktree. + return await withListingConversationReplacements(async () => { + restoring = restoreStructuredTabsIfSupported(context) + if (restoring) { + await restoring + } + return answer() + }) } finally { - context.runtime.startStructuredAgentSessionHistoryRestore?.() + if (restoring) { + context.runtime.startStructuredAgentSessionHistoryRestore?.() + } } } diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts index 2823e29a618d..524fa570f7b4 100644 --- a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -173,6 +173,51 @@ describe('listing chat tabs at startup', () => { } ) + it.each(['listAll', 'subscribeAll'] as const)( + 'derives the /clear replacements once per %s, whatever the tab and worktree count', + async (method) => { + const ids = Array.from({ length: 9 }, (_, index) => `session-${index + 1}`) + for (const [index, sessionId] of ids.entries()) { + await restTestChat(rig, sessionId, { workspaceId: `workspace-${(index % 3) + 1}` }) + } + await rig.crash() + await rig.boot() + const derived = vi.spyOn(rig.host, 'conversationReplacements') + const { runtime, listAll, subscribeAll } = restartedRuntime() + const answer = async () => + method === 'listAll' ? listAll() : (await subscribeAll(() => undefined)).ids + + // The first answer also restores the tabs; the second only lists them. + for (const _listing of [1, 2]) { + derived.mockClear() + expect((await answer()).toSorted()).toEqual(ids.toSorted()) + expect(derived).toHaveBeenCalledOnce() + runtime.cleanupSubscriptionsForConnection('connection-1') + } + } + ) + + it('answers with a /clear committed after an earlier listing', async () => { + await restTestChat(rig, 'session-cleared') + await restTestChat(rig, 'session-fresh', { listed: false }) + await rig.crash() + await rig.boot() + const { listAll } = restartedRuntime() + expect(await listAll()).toEqual(['session-cleared']) + + const record = rig.store.getRecord('session-cleared')! + await rig.store.setConversationCommand('session-cleared', record.lease.runtimeFence, { + command: 'clear', + state: 'completed', + phase: 'committed', + operationId: 'clear-1', + callerKey: 'client-1', + replacementSessionId: 'session-fresh' + }) + + expect(await listAll()).toEqual(['session-fresh']) + }) + it('writes nothing to the record store while it lists', async () => { const ids = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5'] for (const sessionId of ids) { diff --git a/src/main/runtime/structured-conversation-replacements-scope.test.ts b/src/main/runtime/structured-conversation-replacements-scope.test.ts new file mode 100644 index 000000000000..5302fe498b14 --- /dev/null +++ b/src/main/runtime/structured-conversation-replacements-scope.test.ts @@ -0,0 +1,54 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + currentConversationReplacements, + withListingConversationReplacements +} from './structured-conversation-replacements-scope' + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +function hostDeriving() { + const conversationReplacements = vi.fn(() => []) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scope reads only this member. + setStructuredAgentSessionHost({ conversationReplacements } as never) + return conversationReplacements +} + +it('derives once inside a listing, across its awaits', async () => { + const derive = hostDeriving() + + await withListingConversationReplacements(async () => { + currentConversationReplacements() + await new Promise((resolve) => setTimeout(resolve, 1)) + currentConversationReplacements() + }) + + expect(derive).toHaveBeenCalledOnce() +}) + +it('derives on every call outside a listing', () => { + const derive = hostDeriving() + + currentConversationReplacements() + currentConversationReplacements() + + expect(derive).toHaveBeenCalledTimes(2) +}) + +it('derives fresh in work a listing started that outlives it', async () => { + const derive = hostDeriving() + const later = Promise.withResolvers() + + await withListingConversationReplacements(async () => { + currentConversationReplacements() + setTimeout(() => { + currentConversationReplacements() + later.resolve() + }, 5) + }) + await later.promise + + expect(derive).toHaveBeenCalledTimes(2) +}) diff --git a/src/main/runtime/structured-conversation-replacements-scope.ts b/src/main/runtime/structured-conversation-replacements-scope.ts new file mode 100644 index 000000000000..161abaf328e5 --- /dev/null +++ b/src/main/runtime/structured-conversation-replacements-scope.ts @@ -0,0 +1,33 @@ +// Every stored or answered tab snapshot re-applies the /clear replacements, and deriving them walks +// every record. A listing stores and answers once per tab and per worktree, so inside one listing +// they are derived once and shared; everywhere else each call derives them fresh. + +import { AsyncLocalStorage } from 'node:async_hooks' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' + +type ListingScope = { replacements?: readonly ConversationReplacement[]; ended: boolean } + +const listingScope = new AsyncLocalStorage() + +/** Runs one listing request with its replacements derived at most once. */ +export async function withListingConversationReplacements(run: () => Promise): Promise { + const scope: ListingScope = { ended: false } + try { + return await listingScope.run(scope, run) + } finally { + // A timer or task the listing started outlives it and must derive fresh. + scope.ended = true + } +} + +export function currentConversationReplacements(): readonly ConversationReplacement[] { + const scope = listingScope.getStore() + const host = getStructuredAgentSessionHost() + if (!scope || scope.ended) { + return host?.conversationReplacements?.() ?? [] + } + // Only a host's answer is kept: before the listing installs one there is nothing to derive yet. + scope.replacements ??= host?.conversationReplacements?.() + return scope.replacements ?? [] +} From 4768d53505b41d9ac2a4d821d1cff6888749dfe3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:14:35 -0700 Subject: [PATCH 13/21] fix(native-chat): derive /clear replacements right before each listing loop The previous commit kept one derived set of /clear replacements for a whole listing request, reused across the listing's later waits (the PTY census, the authoritative re-collection, subscribeAll's retries). A chat revealed from its history during one of those waits was then answered as its replacement: the stale set still renamed its tab. The request-wide scope is gone. The two loops that carry the cost each derive the set immediately before they run, with nothing awaited in between: the tab restore's projection loop (one snapshot per tab) and the collection's per-worktree answer loop, after its census. Both hand the set to storeMobileSessionSnapshot / toMobileSessionTabsResult through an optional parameter; every other caller derives fresh, as before. A first listing now derives twice (one per loop), later ones once, independent of tab and worktree count. --- ...-mobile-visible-graph-changed-worktrees.ts | 5 +- ...e-prune-mobile-session-tab-group-layout.ts | 10 +++- ...tore-structured-agent-session-tabs-once.ts | 12 ++-- src/main/runtime/orca-runtime-runtime-id.ts | 10 +++- ...runtime-structured-session-restore.test.ts | 4 +- .../methods/structured-session-tab-restore.ts | 21 +++---- ...-agent-session-tab-listing-startup.test.ts | 59 +++++++++++++++++-- ...ed-conversation-replacements-scope.test.ts | 54 ----------------- ...uctured-conversation-replacements-scope.ts | 33 ----------- 9 files changed, 91 insertions(+), 117 deletions(-) delete mode 100644 src/main/runtime/structured-conversation-replacements-scope.test.ts delete mode 100644 src/main/runtime/structured-conversation-replacements-scope.ts diff --git a/src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts b/src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts index 391a33f0e247..3ffa30b8a3a7 100644 --- a/src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts +++ b/src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts @@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult, RuntimeSyncedTab } from '../../sha import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import type { PtyControllerInventory } from './runtime-pty-controller-contract' import { parseExecutionHostId } from '../../shared/execution-host' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' export class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends OrcaRuntimeWithSyncWindowGraph { // Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and @@ -100,9 +101,11 @@ export class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends Or this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() const ptyInventory = await this.refreshMobileSessionPtyInventory() this.restoreLivePairedRendererSessionOwnedMobileTerminals(null) + // Once for every worktree's answer, after the census's await, so a change made during it counts. + const replacements = getStructuredAgentSessionHost()?.conversationReplacements?.() ?? [] const snapshots = [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => this.projectMobileSessionTabsForClient( - this.toMobileSessionTabsResult(snapshot), + this.toMobileSessionTabsResult(snapshot, replacements), clientNavigationId ) ) diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index f59ce30e1aa2..e88643b03401 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -24,7 +24,8 @@ import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-bui import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' -import { currentConversationReplacements } from './structured-conversation-replacements-scope' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { structuredWorkerAgentStatus } from './orchestration/structured-worker-group-addressing' @@ -76,9 +77,12 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime /** Transforms an internal mobile session tab snapshot into a sanitized client payload, resolving launch-agent ownership and normalizing titles. */ protected toMobileSessionTabsResult( - snapshot: RuntimeMobileSessionTabsSnapshot + snapshot: RuntimeMobileSessionTabsSnapshot, + // A caller answering many worktrees in one synchronous loop derives these once for it. + replacements: readonly ConversationReplacement[] = getStructuredAgentSessionHost()?.conversationReplacements?.() ?? + [] ): RuntimeMobileSessionTabsResult { - for (const replacement of currentConversationReplacements()) { + for (const replacement of replacements) { snapshot = replaceConversationInSnapshot(snapshot, replacement) } return projectRuntimeMobileSessionTabs(snapshot, this.getMobileSessionProjectionHost()) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index c075ef76863e..672cb9313574 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -6,7 +6,6 @@ import { replaceConversationInSnapshot } from './structured-conversation-tab-rep import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' -import { currentConversationReplacements } from './structured-conversation-replacements-scope' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionAgentTab, @@ -86,11 +85,14 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu restored.map((session) => session.sessionId) ) // Past the seed, projecting records nothing. - for (const replacement of currentConversationReplacements()) { + // Derived once for the loop below, which stores a snapshot per tab; nothing awaits in between. + const replacements = host?.conversationReplacements?.() ?? [] + for (const replacement of replacements) { this.replaceStructuredAgentSessionTab(replacement) } + const quiet = { activate: false, notify: false, replacements } for (const session of restored) { - this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) + this.projectStructuredAgentSessionTab({ ...session, ...quiet }) } // Each chat's status row arrives as its history opens; one chat's failure leaves the others and // the list alone. Whoever answers with this list starts it, once that answer is out. @@ -152,6 +154,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu activate: boolean notify?: boolean replacesSessionId?: string + /** The /clear replacements, derived once by a caller projecting many tabs in one loop. */ + replacements?: readonly ConversationReplacement[] }): void { const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` @@ -228,7 +232,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs } - const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot) + const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot, input.replacements) if (input.notify !== false) { this.emitMobileSessionTabsSnapshot(stored) } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 515b2ead6167..18178c6ef935 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto' import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' -import { currentConversationReplacements } from './structured-conversation-replacements-scope' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeClientSettingsController } from './runtime-client-settings' @@ -103,9 +104,12 @@ export class OrcaRuntimeWithRuntimeId { /** Single host writer for mobile session snapshots; versions are total-order stamps. */ protected storeMobileSessionSnapshot( worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot + snapshot: RuntimeMobileSessionTabsSnapshot, + // A caller storing many snapshots in one synchronous loop derives these once for it. + replacements: readonly ConversationReplacement[] = getStructuredAgentSessionHost()?.conversationReplacements?.() ?? + [] ): RuntimeMobileSessionTabsSnapshot { - for (const replacement of currentConversationReplacements()) { + for (const replacement of replacements) { snapshot = replaceConversationInSnapshot(snapshot, replacement) } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 880cf09e68cf..78f6a91c7f5d 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -367,7 +367,9 @@ describe('structured session cold restoration', () => { sessionId: 'restored-claude', agent: 'claude', activate: false, - notify: false + notify: false, + // Derived once by the restore for every tab it projects. + replacements: [] }) const restored = await runtime.listMobileSessionTabs('id:workspace-1') diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index 0afba5215883..06c79eb20f7a 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,5 +1,4 @@ import type { RpcContext } from '../core' -import { withListingConversationReplacements } from '../../structured-conversation-replacements-scope' import { isStructuredNativeChatEnabled, supportsStructuredAgentSessions @@ -33,20 +32,14 @@ export async function answerAfterStructuredTabRestore( context: Pick, answer: () => Promise ): Promise { - let restoring: Promise | undefined + const restoring = restoreStructuredTabsIfSupported(context) + if (!restoring) { + return answer() + } try { - // One listing, tab restore included, so the /clear replacements every snapshot it stores or - // answers applies are derived once for it, not once per tab and per worktree. - return await withListingConversationReplacements(async () => { - restoring = restoreStructuredTabsIfSupported(context) - if (restoring) { - await restoring - } - return answer() - }) + await restoring + return await answer() } finally { - if (restoring) { - context.runtime.startStructuredAgentSessionHistoryRestore?.() - } + context.runtime.startStructuredAgentSessionHistoryRestore?.() } } diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts index 524fa570f7b4..cb58ce96de87 100644 --- a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -174,7 +174,7 @@ describe('listing chat tabs at startup', () => { ) it.each(['listAll', 'subscribeAll'] as const)( - 'derives the /clear replacements once per %s, whatever the tab and worktree count', + 'derives the /clear replacements once per loop of %s, whatever the tab and worktree count', async (method) => { const ids = Array.from({ length: 9 }, (_, index) => `session-${index + 1}`) for (const [index, sessionId] of ids.entries()) { @@ -187,11 +187,12 @@ describe('listing chat tabs at startup', () => { const answer = async () => method === 'listAll' ? listAll() : (await subscribeAll(() => undefined)).ids - // The first answer also restores the tabs; the second only lists them. - for (const _listing of [1, 2]) { + // The first answer also restores the tabs (one loop over every tab, one over every worktree); + // the second only lists them. + for (const loops of [2, 1]) { derived.mockClear() expect((await answer()).toSorted()).toEqual(ids.toSorted()) - expect(derived).toHaveBeenCalledOnce() + expect(derived).toHaveBeenCalledTimes(loops) runtime.cleanupSubscriptionsForConnection('connection-1') } } @@ -218,6 +219,56 @@ describe('listing chat tabs at startup', () => { expect(await listAll()).toEqual(['session-fresh']) }) + it('answers with a cleared chat revealed while the listing waits on its census', async () => { + await restTestChat(rig, 'session-cleared') + await restTestChat(rig, 'session-fresh', { listed: false }) + await rig.crash() + await rig.boot() + const { runtime, listAll } = restartedRuntime() + expect(await listAll()).toEqual(['session-cleared']) + const record = rig.store.getRecord('session-cleared')! + await rig.store.setConversationCommand('session-cleared', record.lease.runtimeFence, { + command: 'clear', + state: 'completed', + phase: 'committed', + operationId: 'clear-1', + callerKey: 'client-1', + replacementSessionId: 'session-fresh' + }) + expect(await listAll()).toEqual(['session-fresh']) + // An authoritative census that could not reach every host collects a second time; hold that one. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these runtime members exist; they are protected. + const internal = runtime as unknown as { + supportsAuthoritativeSessionTabsInventory(): boolean + getAuthoritativeSessionTabsInventoryEpoch(): string + refreshMobileSessionPtyInventory(): Promise + } + internal.supportsAuthoritativeSessionTabsInventory = () => true + internal.getAuthoritativeSessionTabsInventoryEpoch = () => 'epoch-1' + let collections = 0 + const inSecond = Promise.withResolvers() + const held = Promise.withResolvers() + releaseHeld = held.resolve + internal.refreshMobileSessionPtyInventory = async () => { + collections += 1 + if (collections === 2) { + inSecond.resolve() + await held.promise + } + return null + } + const listing = listAll() + await inSecond.promise + + // agentSession.reveal from another client: the cleared chat opened from its history. + const revealed = await rig.host.revealSession('session-cleared') + await runtime.publishStructuredAgentSessionTab({ ...revealed, activate: true }) + held.resolve() + + expect(await listing).toEqual(['session-cleared']) + expect(await listAll()).toEqual(['session-cleared']) + }) + it('writes nothing to the record store while it lists', async () => { const ids = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5'] for (const sessionId of ids) { diff --git a/src/main/runtime/structured-conversation-replacements-scope.test.ts b/src/main/runtime/structured-conversation-replacements-scope.test.ts deleted file mode 100644 index 5302fe498b14..000000000000 --- a/src/main/runtime/structured-conversation-replacements-scope.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { - currentConversationReplacements, - withListingConversationReplacements -} from './structured-conversation-replacements-scope' - -afterEach(() => { - setStructuredAgentSessionHost(null) -}) - -function hostDeriving() { - const conversationReplacements = vi.fn(() => []) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scope reads only this member. - setStructuredAgentSessionHost({ conversationReplacements } as never) - return conversationReplacements -} - -it('derives once inside a listing, across its awaits', async () => { - const derive = hostDeriving() - - await withListingConversationReplacements(async () => { - currentConversationReplacements() - await new Promise((resolve) => setTimeout(resolve, 1)) - currentConversationReplacements() - }) - - expect(derive).toHaveBeenCalledOnce() -}) - -it('derives on every call outside a listing', () => { - const derive = hostDeriving() - - currentConversationReplacements() - currentConversationReplacements() - - expect(derive).toHaveBeenCalledTimes(2) -}) - -it('derives fresh in work a listing started that outlives it', async () => { - const derive = hostDeriving() - const later = Promise.withResolvers() - - await withListingConversationReplacements(async () => { - currentConversationReplacements() - setTimeout(() => { - currentConversationReplacements() - later.resolve() - }, 5) - }) - await later.promise - - expect(derive).toHaveBeenCalledTimes(2) -}) diff --git a/src/main/runtime/structured-conversation-replacements-scope.ts b/src/main/runtime/structured-conversation-replacements-scope.ts deleted file mode 100644 index 161abaf328e5..000000000000 --- a/src/main/runtime/structured-conversation-replacements-scope.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Every stored or answered tab snapshot re-applies the /clear replacements, and deriving them walks -// every record. A listing stores and answers once per tab and per worktree, so inside one listing -// they are derived once and shared; everywhere else each call derives them fresh. - -import { AsyncLocalStorage } from 'node:async_hooks' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' - -type ListingScope = { replacements?: readonly ConversationReplacement[]; ended: boolean } - -const listingScope = new AsyncLocalStorage() - -/** Runs one listing request with its replacements derived at most once. */ -export async function withListingConversationReplacements(run: () => Promise): Promise { - const scope: ListingScope = { ended: false } - try { - return await listingScope.run(scope, run) - } finally { - // A timer or task the listing started outlives it and must derive fresh. - scope.ended = true - } -} - -export function currentConversationReplacements(): readonly ConversationReplacement[] { - const scope = listingScope.getStore() - const host = getStructuredAgentSessionHost() - if (!scope || scope.ended) { - return host?.conversationReplacements?.() ?? [] - } - // Only a host's answer is kept: before the listing installs one there is nothing to derive yet. - scope.replacements ??= host?.conversationReplacements?.() - return scope.replacements ?? [] -} From 316d7cc60fec8c62ad9ae30c61431365dd1eecc4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:28:30 -0700 Subject: [PATCH 14/21] fix(native-chat): restore chat history after the tab list one chat at a time The history restore that now runs after the tab list ran four chats at once. Its work is synchronous on the main thread (each open folds the chat's journal), so four lanes add no throughput: they only put up to four fold chunks in every event-loop turn, and each step of a user's read or send waits behind a whole turn. On the Mac measurement rig the first send after a restart waited 2.4-7.4 s behind the restore. It now opens one chat at a time. One chat's failure still costs only that chat, and a chat closed, or a host quitting, while it runs is still skipped. --- .../structured-agent-session-restart-restore.test.ts | 11 +++++++---- .../structured-agent-session-restart-restore.ts | 4 +++- ...ctured-agent-session-restore-after-listing.test.ts | 8 ++++---- ...ructured-agent-session-tab-listing-startup.test.ts | 3 +-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 7540e8159744..8a376b4e0ae9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -35,7 +35,7 @@ const NO_OPEN_DEPS = { describe('restart journal restoration', () => { beforeEach(() => restoreRead.mockReset()) - it('bounds historical journal parsing to four sessions at a time', async () => { + it('opens one chat at a time', async () => { const gate = Promise.withResolvers() let active = 0 let peak = 0 @@ -71,13 +71,16 @@ describe('restart journal restoration', () => { onReadable: () => undefined }) - await vi.waitFor(() => expect(active).toBe(4)) - expect(restoreRead).toHaveBeenCalledTimes(4) + await vi.waitFor(() => expect(active).toBeGreaterThan(0)) + // Held long enough for a second lane, had there been one, to have started. + await new Promise((resolve) => setTimeout(resolve, 20)) + const startedWhileHeld = restoreRead.mock.calls.length gate.resolve() await restoration + expect(startedWhileHeld).toBe(1) expect(restoreRead).toHaveBeenCalledTimes(records.length) - expect(peak).toBe(4) + expect(peak).toBe(1) }) it('lets the event loop run between chats', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index fd621db47cbe..ffbf7dfae22a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -21,7 +21,9 @@ import type { } from './structured-agent-session-conversation-open' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' -const JOURNAL_RESTORE_CONCURRENCY = 4 +// One chat at a time: the restore is CPU-bound on the main thread, so more lanes only lengthen each +// event-loop turn that a user's read or send waits behind. +const JOURNAL_RESTORE_CONCURRENCY = 1 export type StructuredAgentSessionReadRestoreDeps = { openDeps: StructuredAgentSessionConversationOpenDeps & { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts index 7fe8dac70a21..09ac84c75f5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts @@ -27,8 +27,8 @@ afterEach(async () => { vi.restoreAllMocks() }) -/** Boots over a crashed run and starts the pass with the first four opens held, so it has not - * reached the last chat. */ +/** Boots over a crashed run and starts the pass with its first open held, so it has not reached + * the last chat. */ async function bootWithHeldPass() { for (const sessionId of IDS) { await restTestChat(rig, sessionId, { message: `asked ${sessionId}` }) @@ -38,13 +38,13 @@ async function bootWithHeldPass() { const held = Promise.withResolvers() releaseHeld = held.resolve rig.adapter.historyFilePath.mockImplementation(async (sessionId) => { - if (sessionId !== LAST) { + if (sessionId === IDS[0]) { await held.promise } return null }) const pass = host.restoreReadableSessions(IDS) - await vi.waitFor(() => expect(rig.adapter.historyFilePath).toHaveBeenCalledTimes(4)) + await vi.waitFor(() => expect(rig.adapter.historyFilePath).toHaveBeenCalledOnce()) return { host, pass, release: held.resolve } } diff --git a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts index cb58ce96de87..6400a61903ae 100644 --- a/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts +++ b/src/main/runtime/structured-agent-session-tab-listing-startup.test.ts @@ -287,8 +287,7 @@ describe('listing chat tabs at startup', () => { }) it('lists every chat when four fail to open, and the others still get status rows (T5)', async () => { - // More chats than the restore opens at once, with the first four failing: each failure must - // cost only its own chat, not one of the restore's four lanes. + // The first four failing: each failure must cost only its own chat, never the chats after it. const ids = ['session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6'] const failing = ids.slice(0, 4) for (const sessionId of ids) { From 1df09d5e6bfeb447788932d93c3000bce6be2392 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:15:13 -0700 Subject: [PATCH 15/21] fix(native-chat): keep a throwing failure sink from failing the startup chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn. --- ...uctured-agent-session-restart-reconcile.ts | 10 +++++++- ...-session-startup-reconcile-failure.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 23f0e0c6bfae..f349f252ebed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -62,7 +62,15 @@ export function reportEachFailureOnce( const key = failureKey(failure) if (key !== reported) { reported = key - onFailure?.(failure) + try { + onFailure?.(failure) + } catch (sinkError) { + // A throwing sink must not turn the reported failure back into a failed read. + console.warn('[structured-agent-session] reporting a lease bookkeeping failure failed', { + failure, + sinkError + }) + } } }, clear: () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 984475c4dd71..1d7b6621b3ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -194,3 +194,27 @@ it('restores a chat for reading when resolving its recovery cannot write the sto expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) }) + +it.each([ + ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()], + ['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])] +])('keeps the %s resolving when the failure sink throws', async (_step, read) => { + const { host, onLeaseReconcileFailure } = await relaunch() + const sinkError = new Error('error sink failed') + onLeaseReconcileFailure.mockImplementation(() => { + throw sinkError + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + lock.failing = true + try { + await expect(read(host)).resolves.toBeUndefined() + + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('reporting a lease bookkeeping failure failed'), + expect.objectContaining({ failure: expect.objectContaining({ code: 'ELOCKED' }), sinkError }) + ) + } finally { + warn.mockRestore() + } +}) From 0a8bf771e7257b557c41876cb9920ec264c531e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:44:09 -0700 Subject: [PATCH 16/21] test(native-chat): a send during a slow startup lease check starts its agent once, after the check Startup waits for the lease check (which settles each chat's owner from the previous app run) only on the desktop window's path. A phone, the CLI or a remote host's peer can send before it has finished, and the check is not awaited on any RPC write. This pins what keeps that safe. The send is accepted into the chat at once, and its delivery's agent start runs the same check itself, so it waits for the one already in flight. Two sends made while the check is held start one agent, after the check, and both are delivered. Without the agent start's own check, both messages are rejected ("couldn't restart", reason: the lease is still being checked) and no agent starts. --- ...on-send-during-startup-lease-check.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-send-during-startup-lease-check.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-during-startup-lease-check.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-during-startup-lease-check.test.ts new file mode 100644 index 000000000000..e293bec65e7c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-during-startup-lease-check.test.ts @@ -0,0 +1,82 @@ +// A client that is not the desktop window (a phone, the CLI, a remote host's peer) can send before +// startup's lease check has settled the previous run's owner. The send is accepted into the chat +// at once; the agent it needs starts only after the check, through the agent start's own check. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support' +import { + createRestTestRig, + restTestChat, + sendRestTestMessage, + type RestTestRig +} from './structured-agent-session-rest-test-rig' + +const CHAT = 'session-a' + +let rig: RestTestRig +// A failed assertion must not leave teardown waiting on a held probe. +let releaseProbe = (): void => undefined + +beforeEach(async () => { + rig = await createRestTestRig() +}) + +afterEach(async () => { + releaseProbe() + await rig.dispose() + closeTestJournalHostDatabases() + vi.restoreAllMocks() +}) + +describe('a send during a slow startup lease check', () => { + it('is accepted at once, and its agent starts once, after the check', async () => { + await restTestChat(rig, CHAT, { message: 'before the restart' }) + await rig.crash() + const probe = Promise.withResolvers() + const probing = Promise.withResolvers() + releaseProbe = probe.resolve + let checked = false + rig.probeOwner.mockImplementation(async () => { + probing.resolve() + await probe.promise + checked = true + return { outcome: 'pid-absent' } + }) + const host = await rig.boot() + const startupCheck = host.reconcileRestartLeases() + await probing.promise + // Each start, with whether the check had adjudicated the chat's lease by then. + const starts: { checked: boolean; unreconciled: boolean | undefined }[] = [] + const acquire = rig.adapter.acquire.getMockImplementation() + rig.adapter.acquire.mockImplementation(async (input) => { + starts.push({ checked, unreconciled: rig.store.getRecord(CHAT)?.lease.unreconciled }) + if (!acquire) { + throw new Error('the rig acquires through its own implementation') + } + return acquire(input) + }) + + const first = await sendRestTestMessage(rig, CHAT, 'first after the restart') + expect(first, JSON.stringify(first)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + // Queued behind the first one's delivery, which is waiting on the check. + const second = sendRestTestMessage(rig, CHAT, 'second after the restart') + // Long enough for a delivery that did not wait to have started its agent. + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(starts).toEqual([]) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + + probe.resolve() + await startupCheck + expect(await second, 'second send').toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), { + timeout: 10_000 + }) + + expect(starts).toEqual([{ checked: true, unreconciled: false }]) + const submissions = (await host.journalSnapshot(CHAT)).submissions.slice(-2) + expect(submissions.map((entry) => entry.dispatchState)).toEqual(['accepted', 'accepted']) + }) +}) From 531c031ea7bf6628a0c6d11fa06920010d7e9d68 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:39:25 -0700 Subject: [PATCH 17/21] fix(native-chat): keep the runtime-id module within its 400-line limit The runtime module size test failed in CI: orca-runtime-runtime-id.ts had grown to 402 lines. The field for the owed chat-history restore moves to the module whose restoreStructuredAgentSessionTabs owns the tab restore, and an import this PR had reordered goes back to main's order. --- ...ca-runtime-get-structured-agent-session-create-support.ts | 3 +++ src/main/runtime/orca-runtime-runtime-id.ts | 5 +---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c0d4c596ffae..b242208ad127 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -26,6 +26,9 @@ import { parseWorkspaceKey } from '../../shared/workspace-scope' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { + // The history restore a tab restore owes, until a caller that answered with its list starts it. + protected owedStructuredAgentSessionHistoryRestore: (() => void) | null = null + async getStructuredAgentSessionCreateSupport( worktreeSelector: string, agent: 'claude' | 'codex' diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 18178c6ef935..70c3f0fdbeae 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -1,8 +1,8 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { randomUUID } from 'node:crypto' import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' -import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeStore } from './runtime-store-contract' @@ -125,9 +125,6 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionTabRestorePromise: Promise | null = null - // The history restore a tab restore owes, until a caller that answered with its list starts it. - protected owedStructuredAgentSessionHistoryRestore: (() => void) | null = null - // Whether the last tab restore ran with chats on disk but no host to list them. protected structuredAgentSessionInventoryUnverifiable = false From 617b30ef327de63c109000bfa18bfa574eb18fd9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:57:06 -0700 Subject: [PATCH 18/21] test(native-chat): close the chat as the user does in the restore-after-listing test Main's host close now takes the cause, and a closed tab is the user's close. --- .../structured-agent-session-restore-after-listing.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts index 09ac84c75f5d..48736a8c7ac8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-after-listing.test.ts @@ -57,7 +57,7 @@ describe('the restore pass after the tab list', () => { // What closing the tab does. await host.setSessionTabVisibility(LAST, false) - await host.close(LAST) + await host.close(LAST, 'user-close') const publishedAtClose = publishedFor(LAST) release() await pass From 661984b443b775365b6198f71fe63a4b002053e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:32:10 -0700 Subject: [PATCH 19/21] test(native-chat): read a quit's turn verdict the way the next launch does The chat history restore now opens nothing once the host has begun quitting, so the two close-verdict tests that quit and then restore the chat on the same host could no longer open it. They now read the settled turn through a fresh host over the same store and journal, which is what the next launch sees. --- ...ctured-agent-session-close-verdict.test.ts | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 83d80437de3c..ea0870f480e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -124,14 +124,33 @@ async function runningTurn(): Promise { } /** What the settle wrote, read back from the journal the next reader opens. */ -async function settledTurn() { - await host.restoreReadableSessions([SESSION]) - const { items } = await host.journalSnapshot(SESSION) +async function settledTurn(reader = host) { + await reader.restoreReadableSessions([SESSION]) + const { items } = await reader.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] return { turn, settled } } +/** What the next launch reads: a host that has begun quitting opens no chat. */ +async function settledTurnAfterQuit() { + const state = hostTestState() + const reader = new StructuredAgentSessionHost({ + store: state.store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + try { + return await settledTurn(reader) + } finally { + await reader.flushAllStreamedEvents() + } +} + function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) return last?.type === 'status' ? last.session : null @@ -243,7 +262,7 @@ describe('a turn cut short by closing its provider', () => { expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ session: { status: 'idle', turnOutcome: 'interruption' } }) - const { settled } = await settledTurn() + const { settled } = await settledTurnAfterQuit() expect( settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) ).toMatchObject({ key: 'failedAfter' }) @@ -305,7 +324,7 @@ describe('a turn cut short by closing its provider', () => { await host.flushAllStreamedEvents({ trigger: 'quit' }) - const { turn } = await settledTurn() + const { turn } = await settledTurnAfterQuit() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') }) From 5735ee94e1904fc1f814ac9c311741ef0bc115ed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:16:22 -0700 Subject: [PATCH 20/21] chore: put back main's orcad files the merge commit's formatter rewrote The pre-commit formatter ran over every file the merge staged and reformatted three of main's orcad files that this PR does not touch. They go back to main's exact content; the hook is skipped for this commit only, because it would rewrite them again. --- .../ipc/orcad-runtime-maintenance-handlers.ts | 5 +- src/main/ssh/orcad-runtime-decommission.ts | 5 +- src/main/ssh/orcad-runtime-maintenance.ts | 228 +++++++++--------- 3 files changed, 110 insertions(+), 128 deletions(-) diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.ts index 0f16da4de074..1fe7c49cdda8 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.ts @@ -23,10 +23,7 @@ export function registerOrcadRuntimeMaintenanceHandlers(options: { }): void { ipcMain.handle( 'runtimeEnvironments:updateOrcad', - async ( - _event, - args: { selector: string; force?: boolean } - ): Promise => { + async (_event, args: { selector: string; force?: boolean }): Promise => { const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), { selector: requiredString(args?.selector, 'Server'), force: args?.force === true diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts index 4eb9f335fa0a..ffb8d36954cf 100644 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ b/src/main/ssh/orcad-runtime-decommission.ts @@ -7,10 +7,7 @@ import type { OrcadManagedStopResult } from '../../shared/orcad-managed-runtime' import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { - KnownRuntimeEnvironment, - OrcadDeploymentLink -} from '../../shared/runtime-environments' +import type { KnownRuntimeEnvironment, OrcadDeploymentLink } from '../../shared/runtime-environments' import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock' import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index 712d0d67ae00..5ed3a8e16f52 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -72,114 +72,106 @@ export function updateManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs & { force?: boolean } ): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const census = await collectManagedTerminalCensus( - userDataPath, - environment, - context.activationRecord - ) - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const result = await deployOrcad({ - ...managedOrcadSlot(context, deployment.remotePort, args.signal), - localOrcadDir, - target: context.serverTarget, - census, - force: args.force - }) - if (result.outcome === 'installed-not-activated') { - const deferral = { - outcome: 'deferred' as const, - candidateVersion: result.fullVersion, - code: result.code, - reason: result.reason, - forceable: isForceableOrcadDeferral(result.code) - } - recordManagedOrcadUpdateDeferral(environment.id, deferral) - return deferral - } - clearManagedOrcadUpdateDeferral(environment.id) - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - result.fullVersion, - args.signal - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.fullVersion + return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const census = await collectManagedTerminalCensus( + userDataPath, + environment, + context.activationRecord + ) + const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { + signal: args.signal + }) + const result = await deployOrcad({ + ...managedOrcadSlot(context, deployment.remotePort, args.signal), + localOrcadDir, + target: context.serverTarget, + census, + force: args.force + }) + if (result.outcome === 'installed-not-activated') { + const deferral = { + outcome: 'deferred' as const, + candidateVersion: result.fullVersion, + code: result.code, + reason: result.reason, + forceable: isForceableOrcadDeferral(result.code) } + recordManagedOrcadUpdateDeferral(environment.id, deferral) + return deferral } - ) + clearManagedOrcadUpdateDeferral(environment.id) + const readiness = await probeManagedOrcadReadiness( + context, + localOrcadDir, + result.fullVersion, + args.signal + ) + const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) + return { + outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', + environment: redactRuntimeEnvironment(updated), + activeVersion: result.fullVersion + } + }) } export function rollbackManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs ): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const record = context.activationRecord - const target = record.previous - if (!target) { - return { - outcome: 'refused', - code: 'orcad_rollback_no_target', - reason: 'This server has no previous version to roll back to.' - } - } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show - // that build would reach terminals that are still running. - if (census.liveSessions !== 0) { - return { - outcome: 'refused', - code: - census.liveSessions === null - ? 'orcad_rollback_census_unavailable' - : 'orcad_rollback_terminals_running', - reason: - census.liveSessions === null - ? 'The server did not answer how many terminals it runs. Retry when it answers.' - : 'Close the terminals running on this server before rolling it back.' - } - } - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - const targetDir = managedOrcadInstallDir(context, target) - const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) - const result = await rollbackOrcad({ - ...slot, - record, - census, - targetBuildHash, - targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL - }) - if (result.outcome !== 'rolled-back') { - return result + return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const record = context.activationRecord + const target = record.previous + if (!target) { + return { + outcome: 'refused', + code: 'orcad_rollback_no_target', + reason: 'This server has no previous version to roll back to.' } - const readiness = await probeActiveOrcadReadiness( - { ...slot, remoteInstallDir: targetDir }, - { buildHash: targetBuildHash, fullVersion: result.target } - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) + } + const census = await collectManagedTerminalCensus(userDataPath, environment, record) + // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show + // that build would reach terminals that are still running. + if (census.liveSessions !== 0) { return { - outcome: 'rolled-back', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.target, - discarded: result.discarded + outcome: 'refused', + code: + census.liveSessions === null + ? 'orcad_rollback_census_unavailable' + : 'orcad_rollback_terminals_running', + reason: + census.liveSessions === null + ? 'The server did not answer how many terminals it runs. Retry when it answers.' + : 'Close the terminals running on this server before rolling it back.' } } - ) + const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) + const targetDir = managedOrcadInstallDir(context, target) + const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) + const result = await rollbackOrcad({ + ...slot, + record, + census, + targetBuildHash, + targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL + }) + if (result.outcome !== 'rolled-back') { + return result + } + const readiness = await probeActiveOrcadReadiness( + { ...slot, remoteInstallDir: targetDir }, + { buildHash: targetBuildHash, fullVersion: result.target } + ) + const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) + return { + outcome: 'rolled-back', + environment: redactRuntimeEnvironment(updated), + activeVersion: result.target, + discarded: result.discarded + } + }) } /** Finishes or undoes an interrupted activation, rollback or decommission on the host. */ @@ -187,29 +179,25 @@ export function recoverManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs ): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const result = await recoverInterruptedOrcadActivation( - managedOrcadSlot(context, deployment.remotePort, args.signal) - ) - if (result.outcome !== 'recovered') { - return result - } - const updated = result.readiness - ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) - : environment - if (result.activeVersion) { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return { - outcome: 'recovered', - resolution: result.resolution, - activeVersion: result.activeVersion, - environment: redactRuntimeEnvironment(updated) - } + return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const result = await recoverInterruptedOrcadActivation( + managedOrcadSlot(context, deployment.remotePort, args.signal) + ) + if (result.outcome !== 'recovered') { + return result } - ) + const updated = result.readiness + ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) + : environment + if (result.activeVersion) { + await ensureOrcadManagedTunnel(userDataPath, environment.id) + } + return { + outcome: 'recovered', + resolution: result.resolution, + activeVersion: result.activeVersion, + environment: redactRuntimeEnvironment(updated) + } + }) } From d1bf8ae28e268a59b406a1fd61c0a0fa2ce52368 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:35:11 -0700 Subject: [PATCH 21/21] fix(native-chat): the runtime-id module stays within its 400-line limit after the main merge The merge brought main's new idle-evidence lines into a module this branch had grown for its snapshot replacements parameter, which took it past the runtime module size limit. The parameter's type is now inferred from its default and its note joins the method's doc line. --- src/main/runtime/orca-runtime-runtime-id.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 67e28ac4b493..c96ae4ff362c 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto' import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' -import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeClientSettingsController } from './runtime-client-settings' @@ -101,13 +100,11 @@ export class OrcaRuntimeWithRuntimeId { protected mobileSessionTabsByWorktree = new Map() - /** Single host writer for mobile session snapshots; versions are total-order stamps. */ + /** Single host writer for mobile snapshots; total-order versions; a loop passes `replacements`. */ protected storeMobileSessionSnapshot( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, - // A caller storing many snapshots in one synchronous loop derives these once for it. - replacements: readonly ConversationReplacement[] = getStructuredAgentSessionHost()?.conversationReplacements?.() ?? - [] + replacements = getStructuredAgentSessionHost()?.conversationReplacements?.() ?? [] ): RuntimeMobileSessionTabsSnapshot { for (const replacement of replacements) { snapshot = replaceConversationInSnapshot(snapshot, replacement)