diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 6bea136aa22..f321478654d 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -376,7 +376,6 @@ 1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts 1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts 1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts -1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts 2 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts 1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts 1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 0b20726ec77..030d01f7e5e 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -4,6 +4,7 @@ import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' import { isAgentStatusFresh } from '../../../src/shared/agent-status-freshness' import { agentDisplayLabel, + agentStateLabel, agentDotState, agentRowTimeAt, formatTimeAgo @@ -51,6 +52,15 @@ describe('agentDotState', () => { expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( 'Couldn’t confirm' ) + for (const [outcome, label] of [ + ['cancellation', 'Interrupted'], + ['interruption', 'Failed'], + ['failure', 'Failed'] + ] as const) { + expect( + agentStateLabel(agentDotState(row({ state: 'done', ...mainAgentDone(outcome) }), 0)) + ).toBe(label) + } }) it('decays a stale active state to idle, matching desktop', () => { diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index 884cbf3cfba..0b074e09ec2 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -28,6 +28,7 @@ export function agentHookRuntimeDeps( readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), // Structured chats publish here directly because they have no hooks. structuredAgentStatusSink: { + withSavedStatusGroup: (visit) => server.withSavedStructuredStatusGroup(visit), publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), forget: (subject) => server.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index b968df36d2c..83dcc1abe16 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionStatusPublisher } from '../../native-chat/agent-session-wire/structured-agent-session-status-ownership' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import { @@ -23,9 +24,47 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' import { AgentHookServerIngestTerminal } from './server-ingest-terminal' export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal { + withSavedStructuredStatusGroup( + visit: (sink: StructuredAgentSessionStatusPublisher) => void + ): void { + let pendingAt: number | undefined + const defer = (mutate: (notify: () => void) => void): void => { + const before = this.statusChangeNotificationVersion + mutate(() => { + // Reentrant live notifications already include this committed saved mutation. + if (before === this.statusChangeNotificationVersion) { + pendingAt = before + } + }) + } + const flush = (): void => { + const pending = pendingAt + pendingAt = undefined + if (pending !== undefined && pending === this.statusChangeNotificationVersion) { + this.notifyStatusChangeListeners() + } + } + try { + visit({ + publish: (summary, subject) => + defer((notify) => this.ingestStructuredStatus(summary, subject, notify)), + forget: (subject) => defer((notify) => this.dropStructuredStatus(subject, notify)) + }) + } catch (error) { + try { + flush() + } catch { + /* Preserve the original visit failure. */ + } + throw error + } + flush() + } + ingestStructuredStatus( summary: AgentSessionStatusSummary, - subject: AgentStatusStructuredSessionSubject + subject: AgentStatusStructuredSessionSubject, + notify = () => this.notifyStatusChangeListeners() ): void { const parsed = parseAgentStatusSubject(subject) if ( @@ -39,7 +78,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng throw new Error('Structured status does not match its trusted owner subject') } if (!summary.status) { - this.dropStructuredStatus(parsed) + this.dropStructuredStatus(parsed, notify) return } const previous = this.canonicalStatusStore.getParent(parsed) @@ -126,12 +165,15 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const after = structuredStatusLegacyEvent(committed) this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after) - this.notifyStatusChangeListeners() + notify() this.emitEnrichedStatus(after) } /** Only the owning feed can forget a structured row; pane cleanup cannot. */ - dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void { + dropStructuredStatus( + subject: AgentStatusStructuredSessionSubject, + notify = () => this.notifyStatusChangeListeners() + ): void { const parsed = parseAgentStatusSubject(subject) if (!parsed || parsed.kind !== 'structured-session') { throw new Error('Structured status removal requires its exact owner subject') @@ -155,7 +197,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng this.canonicalSubjectsByPane.delete(previous.status.paneKey) } this.commitStatusRowMutation(structuredStatusLegacyEvent(previous.status), undefined) - this.notifyStatusChangeListeners() + notify() this.emitStatusDropped(previous.status.paneKey) } } diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 79e72d18cb9..72f2ebd77a3 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -29,6 +29,7 @@ import { ProgramStatusFacetStore } from './program-status/facet-store' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + protected statusChangeNotificationVersion = 0 protected cliWriterExecutionMatches: CliWriterExecutionMatches | null = null setCliWriterExecutionMatcher(...args: Parameters): void { @@ -280,6 +281,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } protected notifyStatusChangeListeners(): void { + this.statusChangeNotificationVersion++ if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { return } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.spec.ts b/src/main/native-chat/agent-session-journal/journal-host-database.spec.ts index 63bf386ecdb..37160d4fbeb 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.spec.ts @@ -231,9 +231,17 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const database = openTestJournalHostDatabase(root) const installed = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls the supplied producer stop, flush and status-drain stages. host: { stopDelivery: () => {}, + drainStatusWrites: async () => { + expect(database.isClosed).toBe(false) + expect( + readTestJournalRows(database.db, IDENTITY.sessionId, journal.epoch).map( + (row) => row.seq + ) + ).toEqual([1, 2]) + }, flushAllStreamedEvents: async () => { await new Promise((resolve) => setTimeout(resolve, 10)) await journal.appendItem(item(1), text('written during quit'), { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 8744e4b43a6..32fa1d2b0d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -1,3 +1,4 @@ +import * as leaseRelease from './structured-agent-session-lease-release' // All provider operations join close; proven exit ends the child record, unproven close refuses dependent work. import { mkdtemp, rm } from 'node:fs/promises' @@ -374,12 +375,9 @@ it('sends after a proven exit whose resume-point write and lease release both fa await openTurn(connection) refuseWritesOnceClosed(connection) persistHandle.mockRejectedValueOnce(new Error('resume point not written')) - const transition = store.transitionHandoff.bind(store) - vi.spyOn(store, 'transitionHandoff') - .mockImplementationOnce(async () => { - throw new Error('store unavailable') - }) - .mockImplementation(transition) + vi.spyOn(leaseRelease, 'releaseStoredStructuredAgentSessionOwnerAfterExit').mockRejectedValueOnce( + new Error('store unavailable') + ) await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) frame(connection, INTERRUPTED_RESULT) await eventually(() => expect(child()).toBeNull()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts index 365af08fc0f..94139e11a98 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts @@ -43,6 +43,8 @@ async function clear() { envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()) }) expect(result).toMatchObject({ ok: true }) + await rig.host.drainStatusWrites() + expect(rig.store.getRecord(SESSION)?.lastTurn).toBeUndefined() } async function send(text: string, turn: string) { @@ -239,12 +241,14 @@ describe('rewind behind a clear boundary', () => { rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome.status ).toBe('pending') db().exec('DROP TRIGGER fail_rewind') - await recoverStructuredRewind( - rig.host.deps, - SESSION, - journal(), - rig.store.getRecord(SESSION)!.lease.runtimeFence - ) + await rig.host.close(SESSION, 'evict') + await rig.host.revealSession(SESSION) + await rig.host.drainStatusWrites() + expect(rig.host.readStatusSummary(SESSION)).toMatchObject({ + status: 'idle', + latestPrompt: 'kept after clear' + }) + expect(rig.store.getRecord(SESSION)?.lastTurn).toBeUndefined() const recovered = journal().snapshot() await recoverStructuredRewind( rig.host.deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 65a2d9e5093..1e7c787f0d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -1,3 +1,4 @@ +import { StructuredAgentSessionSavedStatus } from './structured-agent-session-saved-status' import { StructuredAgentSessionRequestFailures } from './structured-agent-session-request-failures' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { AT_REST_COMMANDS_READ_BUDGET_MS } from './at-rest-command-catalog' @@ -20,7 +21,10 @@ import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation- import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' +import type { + StructuredAgentSessionStatusFeed, + StructuredAgentSessionStatusSubscriber +} from './structured-agent-session-status-feed' import type { CapturedChildWorkStops } from './structured-agent-session-status-ownership' import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-host-status-feed' import type { StructuredAgentSessionChildWorkProducer } from './structured-agent-session-subagent-rows' @@ -33,6 +37,7 @@ export class StructuredAgentSessionClientDelivery { readonly subscribers: AgentSessionSubscribers readonly waitForSendSettlement: StructuredAgentSessionSendSettlement['wait'] private stopAtRestCommandUpdates: () => void = () => undefined + private readonly savedStatus private readonly statusFeed private readonly turnCompletionFeed readonly requestFailures @@ -63,7 +68,9 @@ export class StructuredAgentSessionClientDelivery { sessions.get(sessionId)?.journal ) }) + this.savedStatus = new StructuredAgentSessionSavedStatus(deps) this.statusFeed = createStructuredAgentSessionHostStatusFeed({ + onPublished: this.savedStatus.observe, sessions, starting, now, @@ -125,6 +132,12 @@ export class StructuredAgentSessionClientDelivery { return adapter.readCommands?.(sessionId) ?? commands } + drainStatusWrites = (): Promise => this.savedStatus.drain() + + publishSavedStatusGroup = ( + visit: Parameters[0] + ): void => this.statusFeed.publishSavedGroup(visit) + publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId) publishConversationName = (sessionId: string): void => @@ -239,7 +252,8 @@ export class StructuredAgentSessionClientDelivery { this.turnCompletionFeed.forget(sessionId) } - closeAll(): void { + async closeAll(): Promise { + await this.drainStatusWrites() this.requestFailures.clear() this.stopAtRestCommandUpdates() this.sendSettlement.closeAll() 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 3eb61a6d517..6672df5aa8e 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 @@ -1,4 +1,5 @@ -// Only user-close supplies personal cancellation; host eviction/quit leaves interrupted work reportable. +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' +// Personal close and intentional shutdown are cancellation; unrelated eviction remains failure news. // Provider and fallback turn-end writers both derive verdict from the journal Stop event. import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -113,7 +114,12 @@ async function runningTurn(): Promise { } async function settledTurn() { - await host.restoreReadableSessions([SESSION]) + if (!host.hasSession(SESSION)) { + await host.flushAllStreamedEvents() + host = new StructuredAgentSessionHost(host.deps) + replaceHostTestState({ store: host.deps.store, host }) + } + await openTestChats(host, [SESSION]) const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] @@ -237,13 +243,13 @@ describe('a turn cut short by closing its provider', () => { expect(turn).toMatchObject({ state: 'interrupted', outcome: 'failure' }) }) - it('leaves a quit as news', async () => { + it('projects an intentional quit as cancellation while retaining its cut notice', async () => { const statuses = await runningTurn() await host.flushAllStreamedEvents({ trigger: 'quit' }) expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ - session: { status: 'idle', turnOutcome: 'interruption' } + session: { status: 'idle', turnOutcome: 'cancellation' } }) const { label, notices } = await settledTurn() expect(label).toBe('workedFor') @@ -296,7 +302,7 @@ describe('a turn cut short by closing its provider', () => { } ) - it("leaves a quit's cut on a turn no adapter settled as news", async () => { + it("retains a quit's raw cut when no adapter settled the turn", async () => { providerEnd = null await runningTurn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts index 4c9e8242c52..ef085686b72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import './structured-agent-session-codex-input-admission.spec' import { continuingStructuredAgentSessionInput, @@ -759,7 +760,7 @@ async function assertInputRecoveredAfterSnapshot(id: string): Promise { probeOwner: async () => ({ outcome: 'pid-absent' }) }) try { - await restarted.restoreReadableSessions() + await openTestChats(restarted) const recovered = await restarted.journalSnapshot(SESSION) const reopened = restarted.collaboratorsForTests().sessions.get(SESSION)!.journal expect(reopened).not.toBe(journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 4698073c5bc..d0c128c9378 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' // Persist spawn identity before handshake so relaunch can stop the exact orphan instead of guessing. import { cp, mkdtemp, rm } from 'node:fs/promises' @@ -28,6 +29,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import * as journalFactory from '../agent-session-journal/journal-store-factory' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -143,7 +145,13 @@ describe('a host that dies while its Codex child is starting', () => { stopOwnerProcess } ) - await relaunched.restoreReadableSessions() + const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal') + openJournal.mockClear() + await relaunched.reconcileRestartLeases() + await relaunched.seedSessionTabs([SESSION]) + expect(openJournal).not.toHaveBeenCalled() + expect(relaunched.hasSession(SESSION)).toBe(false) + expect(await relaunched.revealSession(SESSION)).toMatchObject({ readable: true }) expect(stopOwnerProcess).toHaveBeenCalledWith(CHILD_PID, 'SIGTERM') expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -210,7 +218,7 @@ describe('a create replayed after the host that ran it died', () => { now: () => NOW + elapsedMs } ) - await relaunched.restoreReadableSessions() + await openTestChats(relaunched) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, @@ -254,7 +262,7 @@ describe('a create answered before the host that ran it died', () => { Object.assign(adapterFor(restarted), { supportsCreate: () => true }), { mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) } ) - await relaunched.restoreReadableSessions() + await openTestChats(relaunched) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7749ebd2923..cff5855de28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' // Quiet tools may emit only start/end rows; pre-crash lease renewal supplies the latest observed work time. import { mkdtemp, rm } from 'node:fs/promises' @@ -105,7 +106,7 @@ function crashedClaudeRecord(): AgentSessionRecord { async function seedCrashedStore(): Promise { await seedTestAgentSessionRecordStore(root, { records: [crashedClaudeRecord()] }) - store = await openTestAgentSessionRecordStore(root) + store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] }) } /** Seed an unacknowledged send with a started Bash call and no completion. */ @@ -202,7 +203,7 @@ describe('a turn a crash cut short mid-tool', () => { it('ends at the last renewal, not at the tool call the provider last reported', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await host.restoreReadableSessions() + await openTestChats(host) expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ kind: 'pid-absent', @@ -223,7 +224,7 @@ describe('a turn a crash cut short mid-tool', () => { it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await host.restoreReadableSessions() + await openTestChats(host) const { items } = await host.journalSnapshot(SESSION) const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap( @@ -258,7 +259,7 @@ describe('a turn a crash cut short mid-tool', () => { }) openHost({ probeOwner, stopOwnerProcess }) - await host.restoreReadableSessions() + await openTestChats(host) expect(stopOwnerProcess).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ @@ -363,7 +364,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () ] }) beginAgentSessionRuntimeRecord(root, 'runtime-crashed', TOOL_STARTED_AT - 60_000) - store = await openTestAgentSessionRecordStore(root) + store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] }) openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) expect(await settledTurn()).toMatchObject(INTERRUPTED_TURN) @@ -398,7 +399,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () host.collaboratorsForTests().sessions.get(SESSION) ) ) - await host.restoreReadableSessions() + await openTestChats(host) expect(await host.journalSnapshot(SESSION)).toEqual(settled) }) @@ -432,7 +433,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () expect(await settledTurn()).toMatchObject(INTERRUPTED_TURN) // Reconciliation parks live orphans; recovery Stop supplies exit proof. - await host.restoreReadableSessions() + await openTestChats(host) await drainSession() expect(stopOwnerProcess).toHaveBeenCalledOnce() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index b88a7347c82..9434232f9ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -224,6 +224,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessions.set(sessionId, session) input.clientDelivery.publishRestored(sessionId) await settleInterruptedCommands(deps, sessionId, session) + input.clientDelivery.publishRestored(sessionId) if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { loop.wake(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts index e9cdd4c6327..10b248020a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -31,6 +31,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { statusSink?: StructuredAgentSessionStatusSink conversationNaming?: StructuredAgentSessionConversationNaming } + onPublished?: StructuredAgentSessionStatusFeedDeps['onPublished'] onAgentStarted?: (sessionId: string) => void onChildWorkChanged?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { @@ -61,6 +62,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { const feed = new StructuredAgentSessionStatusFeed({ sessions: args.sessions, starting: args.starting, + onPublished: args.onPublished, getConversation: (sessionId) => args.deps().store.getConversation(sessionId), listConversations: () => args.deps().store.listConversations(), replacedRuntime: (sessionId) => args.deps().store.replacedRuntime(sessionId), 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 dad5d110788..7fc9cbde255 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,3 +1,8 @@ +import { + seedSavedStructuredSessionTabs, + type SavedStructuredStatusGroup +} from './structured-agent-session-saved-status' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -39,19 +44,6 @@ export type StructuredAgentSessionTab = { agent: AgentSessionRecord['provider'] } -export function listStructuredAgentSessionTabs( - sessions: ReadonlyMap< - string, - { params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } } - > -): StructuredAgentSessionTab[] { - return [...sessions.entries()].map(([sessionId, session]) => ({ - sessionId, - workspaceId: session.params.location.workspaceId, - agent: session.params.provider - })) -} - type TabSessions = ReadonlyMap< string, { @@ -62,31 +54,45 @@ type TabSessions = ReadonlyMap< export function createStructuredAgentSessionTabSurface( host: Parameters[0] & { - deps: { + deps: Pick & { store: Pick< AgentSessionRecordStore, - 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getConversation' + | 'getVisibleSessionTabIndex' + | 'getSessionTabId' + | 'showSessionTabs' + | 'getConversation' + | 'listConversations' + | 'listVisibleSessionIds' + | 'isSessionTabVisible' > } }, sessions: TabSessions, - forgetStatus: (sessionId: string) => void + forgetStatus: (sessionId: string) => void, + publishSavedStatusGroup: SavedStructuredStatusGroup, + recover: (ids: readonly string[]) => Promise ) { - // Retain restore failures as tabs so their reads can explain why. - const unopened = new Set() return { - listSessionTabs: (): StructuredAgentSessionTab[] => [ - ...listStructuredAgentSessionTabs(sessions), - ...[...unopened].flatMap((sessionId) => { - const record = sessions.has(sessionId) ? null : host.deps.store.getConversation(sessionId) + listSessionTabs: (): StructuredAgentSessionTab[] => + host.deps.store.listVisibleSessionIds().flatMap((sessionId) => { + const record = host.deps.store.getConversation(sessionId) return record ? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }] : [] - }) - ], - markUnopened: (sessionId: string): void => { - unopened.add(sessionId) - }, + }), + isSessionTabVisible: (id: string) => host.deps.store.isSessionTabVisible(id), + seedSessionTabs: (ids?: readonly string[]): Promise => + seedSavedStructuredSessionTabs( + host.deps, + ids, + (visit) => + publishSavedStatusGroup((publish) => + visit((record, markerAt) => { + publish(record, markerAt) + }) + ), + recover + ), getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(), getSessionTabId: (sessionId: string): string | null => host.deps.store.getSessionTabId(sessionId), @@ -101,7 +107,6 @@ export function createStructuredAgentSessionTabSurface( ): Promise => { await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId) if (!visible) { - unopened.delete(sessionId) if (!options?.deferHiddenNotice) { notifyTabHidden(host, sessionId) } 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 d9a5ed42c21..b00c7dd5be8 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 @@ -6,7 +6,7 @@ import { StructuredConversationCommandController } from './structured-conversati import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import { createRestartReconciler } from './structured-agent-session-restart-reconcile' +import * as leases from './structured-agent-session-restart-reconcile' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' import * as reveal from './structured-agent-session-reveal' @@ -66,8 +66,9 @@ export class StructuredAgentSessionHost { private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState - private readonly reconcileLeases: ReturnType - private readonly restore: ReturnType + private readonly reconcileLeases: ReturnType + private readonly startup: ReturnType + readonly reconcileRestartLeases = () => this.startup.reconcileRestartLeases() private readonly lifetime: lifetime.StructuredAgentSessionConversationLifetime private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery private readonly eventRecovery: StructuredAgentSessionEventRecovery @@ -92,7 +93,7 @@ export class StructuredAgentSessionHost { (id, e) => this.eventRecovery.recoverAfterSinkFailure(id, e), (expired) => this.lifetime.expireStartup(expired) ) - this.reconcileLeases = createRestartReconciler({ + this.reconcileLeases = leases.createRestartReconciler({ store: deps.store, probe: (record) => this.runtimeState.probeRecord(record), ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), @@ -114,16 +115,7 @@ export class StructuredAgentSessionHost { stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), clientDelivery: this.clientDelivery }) - this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { - reconcileLeases: this.reconcileLeases, - reconcileRestartLeases: () => this.reconcileLeases(), - resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), - serialize: (sessionId, task) => this.serialize(sessionId, task), - hasSession: this.hasSession, - // Restore checks hasSession before adoption, preserving live entries. - onReadable: this.conversationDelivery.adoptOpened, - onUnopened: (sessionId) => this.tabs.markUnopened(sessionId) - }) + this.startup = leases.createStartupRecovery(deps, this.reconcileLeases, this.runtimeState) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, store: deps.store, @@ -226,8 +218,15 @@ export class StructuredAgentSessionHost { knownAgentIds = (): readonly string[] => providerSupport.knownAgentIds(this.deps) - private readonly tabs = createTabs(this, this.sessions, this.clientDelivery.forgetStatus) + private readonly tabs = createTabs( + this, + this.sessions, + this.clientDelivery.forgetStatus, + this.clientDelivery.publishSavedStatusGroup, + (ids) => this.startup.recover(ids) + ) listSessionTabs = this.tabs.listSessionTabs + isSessionTabVisible = this.tabs.isSessionTabVisible getPersistedVisibleSessionTabIndex = this.tabs.getPersistedVisibleSessionTabIndex getSessionTabId = this.tabs.getSessionTabId showSessionTabs = this.tabs.showSessionTabs @@ -237,9 +236,9 @@ export class StructuredAgentSessionHost { holdsSessions = (): boolean => this.deps.store.holdsRecords() onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener) - reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() + seedSessionTabs = this.tabs.seedSessionTabs - restoreReadableSessions = (ids?: readonly string[]) => this.restore.restoreReadableSessions(ids) + drainStatusWrites = (): Promise => this.clientDelivery.drainStatusWrites() revealSession = (sessionId: string): Promise => reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 0891f613004..f5d4b818b42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -168,7 +169,7 @@ describe('a record an older build left mid terminal handoff', () => { claimStatus: 'released' }) - await host.restoreReadableSessions() + await openTestChats(host) expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -201,7 +202,7 @@ describe('a record an older build left mid terminal handoff', () => { }) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - await host.restoreReadableSessions() + await openTestChats(host) expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -221,7 +222,7 @@ describe('a record an older build left mid terminal handoff', () => { claimStatus: 'live' }) - await host.restoreReadableSessions() + await openTestChats(host) expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -244,7 +245,7 @@ describe('a record an older build left mid terminal handoff', () => { }) probe.mockResolvedValue({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) - await host.restoreReadableSessions() + await openTestChats(host) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'conflicted', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support.ts new file mode 100644 index 00000000000..c54a21581f8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support.ts @@ -0,0 +1,13 @@ +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +/** Exercise startup and opening through the public production paths. */ +export async function openTestChats( + host: StructuredAgentSessionHost, + ids?: readonly string[] +): Promise { + await host.reconcileRestartLeases() + await host.seedSessionTabs(ids) + for (const sessionId of ids ?? host.listSessionTabs().map((tab) => tab.sessionId)) { + await host.revealSession(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts deleted file mode 100644 index 9a79039e4f7..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session-record.test-fixture' -import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionConversationOpenDeps } from './structured-agent-session-conversation-open' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import { publishJournalSessionEpoch } from '../agent-session-journal/journal-row-table' -import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const SESSION_ID = 'codex_read_restore_fixture' -const WORKSPACE_ID = 'repo-1::/tmp/workspace' - -const RECORD: AgentSessionRecord = { - ...agentSessionRecordFixture( - agentSessionLeaseFixture({ sessionId: SESSION_ID, runtimeFence: 1 }) - ), - sessionId: SESSION_ID, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE_ID, - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - providerHandleChain: [ - { - linkId: 'link-1', - handle: codexProviderHandle('thread-1'), - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ] -} - -const store: StructuredAgentSessionConversationOpenDeps['store'] = { - getConversation: (sessionId: string) => - sessionId === SESSION_ID ? agentSessionConversationView(RECORD) : null, - replacedRuntime: () => undefined -} - -let journalRoot: string -const openDeps = () => ({ - store, - journalDatabase: openTestJournalHostDatabase(journalRoot), - logger: recordingStructuredAgentSessionLogger().logger -}) -const opened: AgentSessionJournal[] = [] - -beforeEach(async () => { - journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-')) -}) - -afterEach(async () => { - await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) - closeTestJournalHostDatabases() - await rm(journalRoot, { recursive: true, force: true }) -}) - -describe('read restore', () => { - it('publishes a session with a journal, starting no agent', async () => { - const deps = openDeps() - const restored = await restoreStructuredAgentSessionRead(deps, SESSION_ID) - expect(restored).toBeNull() - publishJournalSessionEpoch( - deps.journalDatabase.db, - { sessionId: SESSION_ID, workspaceId: WORKSPACE_ID }, - 'epoch-1' - ) - - const published = await restoreStructuredAgentSessionRead(deps, SESSION_ID) - - expect(published).not.toBeNull() - opened.push(published!.session.journal) - expect(published!.session.child).toBeNull() - }) - - it('drops a session with no journal', async () => { - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).toBeNull() - }) - - it('drops a session with no record', async () => { - const restored = await restoreStructuredAgentSessionRead(openDeps(), 'unknown-session') - - expect(restored).toBeNull() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts deleted file mode 100644 index 30344ffb1ef..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' -import { - openStructuredAgentSessionConversationJournal, - type OpenedStructuredAgentSessionConversation, - type StructuredAgentSessionConversationOpenDeps -} from './structured-agent-session-conversation-open' - -/** Open only existing journals without founding empty chats; recovery may still write the crash boundary. */ -export async function restoreStructuredAgentSessionRead( - deps: StructuredAgentSessionConversationOpenDeps, - sessionId: string -): Promise { - const record = deps.store.getConversation(sessionId) - if (!record) { - return null - } - if (readJournalSessionEpoch(deps.journalDatabase.db, sessionId) === null) { - return null - } - return openStructuredAgentSessionConversationJournal(deps, record) -} 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 deleted file mode 100644 index 62557e7b18f..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session-record.test-fixture' -import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' - -const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) - -vi.mock('./structured-agent-session-restart-restore', () => ({ - restoreStructuredAgentSessionsOnRestart: restoreOnRestart -})) - -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-readable-restorer-')) -afterAll(() => { - closeTestJournalHostDatabases() - rmSync(stateDirectory, { recursive: true, force: true }) -}) - -describe('StructuredAgentSessionReadableRestorer', () => { - beforeEach(() => { - restoreOnRestart.mockReset().mockResolvedValue(undefined) - }) - - it('passes targeted records to the restore pool in visible-first order', async () => { - const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map((sessionId) => - agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) - ) - const restorer = new StructuredAgentSessionReadableRestorer({ - openDeps: { - store: { - getRecord: () => null, - getConversation: () => null, - listConversations: () => records.map(agentSessionConversationView), - replacedRuntime: () => undefined - }, - journalDatabase: openTestJournalHostDatabase(stateDirectory), - logger: recordingStructuredAgentSessionLogger().logger - }, - reconcileAll: async () => true, - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => undefined - }) - - await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b']) - - expect(restoreOnRestart).toHaveBeenCalledOnce() - expect(restoreOnRestart.mock.calls[0][0].records.map((record) => record.sessionId)).toEqual([ - 'visible-a', - 'visible-b', - 'background-a', - 'background-b' - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts deleted file mode 100644 index 6e27e961f08..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { StructuredAgentSessionReadRestoreDeps } from './structured-agent-session-restart-restore' -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' - -export class StructuredAgentSessionReadableRestorer { - private restorePromise: Promise | null = null - - constructor(private readonly input: StructuredAgentSessionReadRestoreDeps) {} - - restore(sessionIds?: readonly string[]): Promise { - this.restorePromise ??= this.restoreReadableSessions(sessionIds).catch((error: unknown) => { - this.restorePromise = null - throw error - }) - return this.restorePromise - } - - private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { - const targetOrder = sessionIds - ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) - : null - const records = this.input.openDeps.store - .listConversations() - .filter((record) => !targetOrder || targetOrder.has(record.sessionId)) - if (targetOrder) { - records.sort( - (left, right) => targetOrder.get(left.sessionId)! - targetOrder.get(right.sessionId)! - ) - } - await restoreStructuredAgentSessionsOnRestart({ - ...this.input, - records - }) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 80cca8d04d9..091af13c02b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -98,7 +99,7 @@ function openHost(overrides: Partial = {}): void async function reopenStore(): Promise { await abandonStructuredAgentSessionHost(host) - store = await openTestAgentSessionRecordStore(root) + store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] }) } beforeEach(async () => { @@ -120,7 +121,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await openTestAgentSessionRecordStore(root) + store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] }) openHost() }) @@ -183,7 +184,7 @@ describe('recovery exits', () => { mintSpawnToken: () => 'spawn-c', probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await host.restoreReadableSessions() + await openTestChats(host) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -238,7 +239,7 @@ describe('recovery exits', () => { }) }) - it('heals a stranded native owner during startup restore, and spawns nothing until work asks', async () => { + it('heals a stranded native owner on explicit recovery, and spawns nothing until work asks', async () => { expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() @@ -255,9 +256,9 @@ describe('recovery exits', () => { stopOwnerProcess }) - await host.restoreReadableSessions() + await openTestChats(host) - // Startup heals ownership without spawning; only later work starts a replacement child. + // Explicit recovery heals ownership without spawning; only later work starts a replacement child. expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') expect(acquire).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -295,7 +296,7 @@ describe('recovery exits', () => { const outgoingHost = host const outgoingStore = store supersededHosts.add(outgoingHost) - store = await openTestAgentSessionRecordStore(root) + store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] }) const realProbe = createStructuredAgentSessionOwnerProbe('local') let overlapDriven = false openHost({ @@ -316,7 +317,7 @@ describe('recovery exits', () => { } }) - await host.restoreReadableSessions() + await openTestChats(host) expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 2, claimStatus: 'released', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts index a0aeecc449b..02db33c677f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -108,7 +109,7 @@ async function startServer(options: { newProcess: boolean }): Promise { beginAgentSessionRuntimeIncarnationForTest() } rig.clock.now = RESTARTED_AT - rig.store = await openTestAgentSessionRecordStore(rig.root) + rig.store = await openTestAgentSessionRecordStore(rig.root, { legacySessionTabIds: [SESSION] }) rig.host = new StructuredAgentSessionHost({ ...old.deps, store: rig.store, @@ -150,7 +151,7 @@ describe('a server replaced mid-turn on the same execution host', () => { it('interrupts the cut turn at its last saved output with no provider identity left', async () => { await streamThenServerKilled() await startServer({ newProcess: true }) - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) await expectCutTurnSettledOnce() await rig.host.reconcileRestartLeases() @@ -210,19 +211,19 @@ describe('a server replaced mid-turn on the same execution host', () => { await streamThenServerKilled() // Reopening in the same process replaces no runtime and proves no turn end. await startServer({ newProcess: false }) - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) await rig.host.reconcileRestartLeases() await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) const left = turnsOf((await rig.host.journalSnapshot(SESSION)).items) expect(left).toEqual([expect.objectContaining({ state: 'unverifiable' })]) await startServer({ newProcess: true }) - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) await expectCutTurnSettledOnce() const settled = await rig.host.journalSnapshot(SESSION) await startServer({ newProcess: true }) - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) expect((await rig.host.journalSnapshot(SESSION)).items).toEqual(settled.items) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index bc3e8497aab..f78a8af6854 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -21,7 +21,7 @@ export type StructuredAgentSessionRestartCandidateReader = ( ) => StructuredAgentSessionResumableSet export function createStructuredAgentSessionRestartCandidateReader(deps: { - /** Listing reveals marker chats before these map reads. */ + /** Only explicit actions open marker chats; passive listings use saved previews. */ sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter @@ -36,10 +36,12 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { supportsRecord: (record) => hostCanStartRecord(deps, record), movedOn: deps.movedOn, savedByNewerOrca: deps.savedByNewerOrca, - latestPrompt: (sessionId) => - latestStructuredAgentSessionPrompt( - deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] - ), + latestPrompt: (sessionId) => { + const session = deps.sessions.get(sessionId) + return session + ? latestStructuredAgentSessionPrompt(session.journal.snapshot().items) + : (deps.getRecord(sessionId)?.lastTurn?.prompt ?? '') + }, leaseState }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 4248ff1eecf..62a68bbac34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -67,7 +67,6 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { retryable: (marker: AgentSessionResumeMarker) => boolean /** Preserve newer-chat failures for compatible builds, hidden here. */ savedByNewerOrca: (sessionId: string) => boolean - reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise logger: StructuredAgentSessionLogger now: () => number /** Share the capsule mutation lane with offer operations. */ @@ -116,7 +115,6 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { const list = async (): Promise => { const failures = await read() - await deps.reveal(failures.map((failure) => failure.marker)) return failures.flatMap(toRow) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts index 0f28edadd56..da5c1436686 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -57,10 +57,13 @@ function offersFile(root: string): Promise { async function keptWithoutAWord( host: Awaited>['host'], root: string, - acquire: ReturnType + acquire: ReturnType, + unopenedJournalIsNewer = false ) { const before = await offersFile(root) - expect(await host.restartResume.list()).toEqual([]) + expect((await host.restartResume.list()).map((offer) => offer.sessionId)).toEqual( + unopenedJournalIsNewer ? [SESSION] : [] + ) expect(await offersFile(root)).toEqual(before) expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([]) expect(acquire).not.toHaveBeenCalled() @@ -71,10 +74,10 @@ async function keptWithoutAWord( expect(await capsule.listFailed(NOW)).toEqual([]) } -it('keeps the offer of a chat whose journal a newer Orca wrote, and offers, runs and counts nothing', async () => { +it('lists an unopened newer journal passively and preserves its offer when acting is refused', async () => { const { host, root, acquire } = await interruptedRestart() newerOrcaRow(root) - await keptWithoutAWord(host, root, acquire) + await keptWithoutAWord(host, root, acquire, true) }) it('keeps the offer of every chat in a newer Orca database, and offers, runs and counts nothing', async () => { @@ -107,7 +110,7 @@ it('keeps the offer and files nothing when the send is refused as a newer Orca c expect(await host.restartResume.listFailures()).toEqual([]) }) -it('keeps a failure already filed for a chat a newer Orca saved since, but does not show it', async () => { +it('lists a filed failure passively and hides it once an action discovers a newer journal', async () => { const { host, root, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) @@ -116,6 +119,8 @@ it('keeps a failure already filed for a chat a newer Orca saved since, but does await host.close(SESSION, 'evict') newerOrcaRow(root) + expect(await host.restartResume.listFailures()).toMatchObject([{ sessionId: SESSION }]) + await host.restartResume.continueAfterRestart([SESSION], 'retry') expect(await host.restartResume.listFailures()).toEqual([]) expect( (await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).map((f) => f.marker.sessionId) @@ -173,7 +178,8 @@ it.each([ if (listFirst) { expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([ - SESSION + SESSION, + NEWER ]) } await host.restartResume.dismiss(undefined, audience) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index a494c56c225..7b7accfd3d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -124,7 +125,7 @@ it('keeps the offer when a tabbed chat is restored at boot (R-05)', async () => const { host, store, root, acquire } = await offered() await store.setSessionTabVisibility(SESSION, true) - await host.restoreReadableSessions([SESSION]) + await openTestChats(host, [SESSION]) expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) @@ -203,7 +204,7 @@ it('keeps the offer withdrawn after the idle sweep closes the chat and it is rea await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) }) -it('keeps a failure not retryable after the user moved on and the sweep closed the chat', async () => { +it('rechecks a swept chat for supersession when the user explicitly retries', async () => { const state = await offered() const { host, clock } = state state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) @@ -216,6 +217,11 @@ it('keeps a failure not retryable after the user moved on and the sweep closed t await sweepOnce(host) expect(host.hasSession(SESSION)).toBe(false) + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: true }]) + expect(host.hasSession(SESSION)).toBe(false) + expect( + (await host.restartResume.continueAfterRestart([SESSION], 'stale-retry')).continued + ).toEqual([]) expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts index 854143257c4..1b0d47ffc4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { codexStreamingJournalItem } from '../../codex/codex-structured-item-translation' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' @@ -89,7 +90,7 @@ async function beginTurn() { async function expectInterruptedOutput(completedAt = LAST_CHUNK_AT): Promise { await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) const snapshot = await rig.host.journalSnapshot(SESSION) const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) expect(turn).toMatchObject({ state: 'interrupted', completedAt }) @@ -167,7 +168,7 @@ describe('provider output survives host restart', () => { rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.lastProviderActivityAt(1) ).toBe(LAST_CHUNK_AT) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) const snapshot = await rig.host.journalSnapshot(SESSION) const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) expect(turn).toMatchObject({ state: 'interrupted', completedAt: LAST_CHUNK_AT }) @@ -192,7 +193,7 @@ describe('provider output survives host restart', () => { } expect(rig.store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(HOST_TEST_NOW) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) const snapshot = await rig.host.journalSnapshot(SESSION) const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) expect(turn).toMatchObject({ state: 'interrupted', completedAt: LAST_CHUNK_AT }) @@ -322,7 +323,7 @@ describe('provider output survives host restart', () => { ) await rig.host.flushStreamedEvents(SESSION) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) const snapshot = await rig.host.journalSnapshot(SESSION) const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) expect(turn).toMatchObject({ state: 'interrupted', completedAt: LAST_CHUNK_AT }) 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 54f01b636c5..a8547ec3742 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 @@ -6,6 +6,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { classifyStoreFailure } from './structured-agent-session-attach' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' const MAX_RECONCILIATION_PASSES = 8 @@ -133,3 +134,42 @@ function hasUnreconciledLease(store: AgentSessionRecordStore, sessionId?: string ? store.listRecords().some((record) => record.lease.unreconciled) : store.getRecord(sessionId)?.lease.unreconciled === true } + +/** Keep lost-transport cleanup independent of history; reader bookkeeping never grants ownership. */ +export function createStartupRecovery( + deps: { store: Pick; logger: StructuredAgentSessionLogger }, + reconcileLeases: (sessionId?: string) => Promise, + runtime: { resolveRecovery: (sessionId: string) => Promise } +): { + reconcileRestartLeases: () => Promise + recover: (ids: readonly string[]) => Promise +} { + const failures = reportEachFailureOnce(deps.logger) + const reconcile = createReaderReconcile(reconcileLeases, failures) + return { + reconcileRestartLeases: async () => { + await reconcile() + }, + recover: async (ids) => { + if (!ids.length || !(await reconcile())) { + return + } + let settled = true + await mapWithConcurrency(ids, 4, async (id) => { + if (!settled || deps.store.getRecord(id)?.lease.handoffStage !== 'recovering') { + return + } + if (!(await reconcile(id))) { + settled = false + return + } + try { + await runtime.resolveRecovery(id) + } catch (error) { + settled = false + failures.report(error) + } + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts index 7545574f803..f7f8d2eae6c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { StructuredAgentRegistry } from './structured-agent-registry' import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' @@ -186,7 +187,7 @@ describe.each([ const completedAt = bufferedOutput ? CONNECTION_CLOSED_AT : LAST_CHUNK_AT expect(journal.lastProviderActivityAt(1)).toBe(completedAt) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) const snapshot = await rig.host.journalSnapshot(SESSION) const background = snapshot.items.find( (item) => @@ -204,7 +205,7 @@ describe.each([ rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.lastProviderActivityAt(1) ).toBe(completedAt) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) expect((await rig.host.journalSnapshot(SESSION)).items).toEqual(snapshot.items) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts deleted file mode 100644 index 4567d84d5c0..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' - -describe('StructuredAgentSessionRestartRestoreGate', () => { - it('runs one successful restore across concurrent and later discovery', async () => { - const gate = new StructuredAgentSessionRestartRestoreGate() - const restore = vi.fn(async () => undefined) - - await Promise.all([gate.run(restore), gate.run(restore), gate.run(restore)]) - await gate.run(restore) - - expect(restore).toHaveBeenCalledOnce() - }) - - it('allows a failed restore to be retried', async () => { - const gate = new StructuredAgentSessionRestartRestoreGate() - const restore = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('restore failed')) - .mockResolvedValue(undefined) - - await expect(gate.run(restore)).rejects.toThrow('restore failed') - await expect(gate.run(restore)).resolves.toBeUndefined() - - expect(restore).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts deleted file mode 100644 index dd4237dcb28..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class StructuredAgentSessionRestartRestoreGate { - private current: Promise | null = null - - run(restore: () => Promise): Promise { - if (this.current) { - return this.current - } - const tracked = restore().catch((error: unknown) => { - if (this.current === tracked) { - this.current = null - } - throw error - }) - this.current = tracked - return tracked - } -} 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 deleted file mode 100644 index 9d206bbe087..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session-record.test-fixture' -import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' - -const { restoreRead } = vi.hoisted(() => ({ - restoreRead: vi.fn() -})) - -vi.mock('./structured-agent-session-read-restore', () => ({ - restoreStructuredAgentSessionRead: restoreRead -})) - -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-restart-restore-')) -afterAll(() => { - closeTestJournalHostDatabases() - rmSync(stateDirectory, { recursive: true, force: true }) -}) - -const NO_OPEN_DEPS = { - store: { - getRecord: (sessionId: string) => - agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })), - getConversation: (sessionId: string) => - agentSessionConversationView( - agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) - ), - listConversations: () => [], - replacedRuntime: () => undefined - }, - journalDatabase: openTestJournalHostDatabase(stateDirectory), - logger: recordingStructuredAgentSessionLogger().logger -} - -describe('restart journal restoration', () => { - beforeEach(() => restoreRead.mockReset()) - - it('bounds historical journal parsing to four sessions at a time', async () => { - const gate = Promise.withResolvers() - let active = 0 - let peak = 0 - restoreRead.mockImplementation(async (_deps, sessionId: string) => { - active += 1 - peak = Math.max(peak, active) - await gate.promise - active -= 1 - return { - session: { - journal: {}, - params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, - child: null, - sessionId - } - } - }) - const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` })) - - const restoration = restoreStructuredAgentSessionsOnRestart({ - openDeps: NO_OPEN_DEPS, - records, - reconcileAll: async () => true, - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => undefined - }) - - await vi.waitFor(() => expect(active).toBe(4)) - expect(restoreRead).toHaveBeenCalledTimes(4) - gate.resolve() - await restoration - - expect(restoreRead).toHaveBeenCalledTimes(records.length) - expect(peak).toBe(4) - }) - - it('lets the event loop run between chats', async () => { - // Synchronous opens must still yield to event-loop turns. - let turns = 0 - let ticking = true - const tick = (): void => { - turns += 1 - if (ticking) { - setImmediate(tick) - } - } - setImmediate(tick) - const turnsSeen = new Set() - restoreRead.mockImplementation(async () => { - turnsSeen.add(turns) - return null - }) - const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` })) - - await restoreStructuredAgentSessionsOnRestart({ - openDeps: NO_OPEN_DEPS, - records, - reconcileAll: async () => true, - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => undefined - }) - ticking = false - - expect(restoreRead).toHaveBeenCalledTimes(records.length) - expect(turnsSeen.size).toBeGreaterThanOrEqual(records.length / 4) - }) - - it('settles what a gone generation left running after recovery resolution, before publishing', async () => { - const calls: string[] = [] - const params: AgentSessionAttachParams = { - envelope: { - sessionId: 'session-1', - clientOperationId: 'read-restore:session-1', - expectedRuntimeFence: 4, - payloadFingerprint: 'fingerprint' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, - runtimeKind: 'native' - } - const restored = { - session: { journal: {}, params, child: null } - } - // Opening settles leftovers after recovery and before publication. - restoreRead.mockImplementation(async () => { - calls.push('open') - return restored - }) - - await restoreStructuredAgentSessionsOnRestart({ - records: [{ sessionId: 'session-1' }], - openDeps: NO_OPEN_DEPS, - reconcileAll: async () => true, - reconcile: async () => true, - resolveRecovery: async () => { - calls.push('resolveRecovery') - return true - }, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: (_sessionId, readable) => { - calls.push(readable === restored ? 'onReadable:restored' : 'onReadable') - } - }) - - expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) - }) - - describe('once lease bookkeeping fails in a pass', () => { - const records = Array.from({ length: 8 }, (_, index) => ({ sessionId: `session-${index}` })) - const restore = ( - bookkeeping: Pick< - Parameters[0], - 'reconcileAll' | 'reconcile' | 'resolveRecovery' - > - ) => - restoreStructuredAgentSessionsOnRestart({ - openDeps: NO_OPEN_DEPS, - records, - ...bookkeeping, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => undefined - }) - - /** Overlap workers with a slow store failure. */ - const slowFailure = async (): Promise => { - await new Promise((resolve) => setTimeout(resolve, 5)) - return false - } - - beforeEach(() => restoreRead.mockResolvedValue(null)) - - it('opens with one whole-host check, then checks each chat while it holds', async () => { - const reconcileAll = vi.fn(async () => true) - const reconcile = vi.fn(async (_sessionId: string) => true) - - await restore({ reconcileAll, reconcile, resolveRecovery: async () => true }) - - expect(reconcileAll).toHaveBeenCalledOnce() - expect(reconcileAll).toHaveBeenCalledWith() - expect(reconcile.mock.calls.map(([sessionId]) => sessionId).sort()).toEqual( - records.map((record) => record.sessionId).sort() - ) - }) - - it('skips it for every chat when the pass check fails, and still opens them all', async () => { - const reconcileAll = vi.fn(slowFailure) - const reconcile = vi.fn(async () => true) - const resolveRecovery = vi.fn(async () => true) - - await restore({ reconcileAll, reconcile, resolveRecovery }) - - expect(reconcileAll).toHaveBeenCalledOnce() - expect(reconcile).not.toHaveBeenCalled() - 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({ - reconcileAll: async () => true, - reconcile: async () => true, - resolveRecovery - }) - - // After failure, only already-started bookkeeping calls may finish. - expect(resolveRecovery.mock.calls.length).toBeLessThanOrEqual(4) - expect(restoreRead).toHaveBeenCalledTimes(records.length) - }) - }) - - it('opens every other chat when one chat cannot be opened, and the pass still settles', async () => { - restoreRead.mockImplementation(async (_deps, sessionId: string) => { - if (sessionId === 'session-0') { - throw new Error('this chat cannot be opened') - } - return { session: { journal: {}, params: {}, child: null, sessionId } } - }) - const readable: string[] = [] - const log = recordingStructuredAgentSessionLogger() - const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` })) - - await expect( - restoreStructuredAgentSessionsOnRestart({ - openDeps: { ...NO_OPEN_DEPS, logger: log.logger }, - records, - reconcileAll: async () => true, - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: (sessionId) => { - readable.push(sessionId) - } - }) - ).resolves.toBeUndefined() - - expect(readable).toEqual(records.slice(1).map((record) => record.sessionId)) - expect(log.entries).toEqual([ - expect.objectContaining({ - fields: expect.objectContaining({ scope: 'restart-restore', sessionId: 'session-0' }) - }) - ]) - }) - - it('does not settle again when a second restore finds the session already open', async () => { - restoreRead.mockResolvedValue({ - session: { journal: {}, params: {}, child: null } - }) - - await restoreStructuredAgentSessionsOnRestart({ - records: [{ sessionId: 'session-1' }], - openDeps: NO_OPEN_DEPS, - reconcileAll: async () => true, - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: () => true, - onReadable: () => undefined - }) - - expect(restoreRead).not.toHaveBeenCalled() - }) -}) 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 deleted file mode 100644 index 7071a3d06da..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Reconcile ownership and restore readable journals without spawning providers for every saved chat. - -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { - OpenedStructuredAgentSessionConversation, - StructuredAgentSessionConversationOpenDeps -} from './structured-agent-session-conversation-open' -import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' - -const JOURNAL_RESTORE_CONCURRENCY = 4 - -export type StructuredAgentSessionReadRestoreDeps = { - openDeps: StructuredAgentSessionConversationOpenDeps & { - store: Pick - } - // Reader bookkeeping cannot grant a writer and must not block reads. - reconcileAll: () => Promise - reconcile: (sessionId: string) => Promise - /** Failed writes retry strictly on the next attach/send. */ - resolveRecovery: (sessionId: string) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - hasSession: (sessionId: string) => boolean - /** Failed opens retain tabs for readable failure/retry. */ - onUnopened?: (sessionId: string) => void - onReadable: ( - sessionId: string, - opened: OpenedStructuredAgentSessionConversation - ) => Promise | void -} - -async function restoreOneStructuredAgentSessionRead( - input: StructuredAgentSessionReadRestoreDeps, - sessionId: string, - settleLeases: (sessionId: string) => Promise -): Promise { - await settleLeases(sessionId) - await input.serialize(sessionId, () => - restoreOneStructuredAgentSessionReadUnderSerialize(input, sessionId) - ) -} - -async function restoreOneStructuredAgentSessionReadUnderSerialize( - input: Pick, - sessionId: string -): Promise { - if (input.hasSession(sessionId)) { - return - } - const opened = await restoreStructuredAgentSessionRead(input.openDeps, sessionId) - if (!opened) { - return - } - await input.onReadable(sessionId, opened) -} - -export async function restoreStructuredAgentSessionsOnRestart( - input: StructuredAgentSessionReadRestoreDeps & { - records: Pick[] - } -): Promise { - if (input.records.length === 0) { - return - } - // Avoid per-chat store retries after a failed host-wide pass; explicit actions retry later. - let settled = await input.reconcileAll() - const settleLeases = async (sessionId: string): Promise => { - if ( - settled && - input.openDeps.store.getRecord(sessionId) && - !((await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId))) - ) { - settled = false - } - } - await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { - // Synchronous SQLite opens need a macrotask per chat to keep startup responsive. - await yieldToEventLoop() - // Isolate failed opens so other chats still restore. - await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases).catch( - (error: unknown) => { - input.openDeps.logger.warn('restoring a chat at startup failed', { - scope: 'restart-restore', - sessionId, - error - }) - input.onUnopened?.(sessionId) - } - ) - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index a28fbb498ec..f4d6dd35c94 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -94,7 +94,6 @@ export function createStructuredAgentSessionRestartResume( getRecord: deps.store.getConversation, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, savedByNewerOrca: newerOrca.has, - reveal: (markers) => revealMarkers(markers), logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation @@ -117,7 +116,6 @@ export function createStructuredAgentSessionRestartResume( audience?: StructuredAgentSessionRestartAudience ): Promise => { const markers = await readMarkers() - await revealMarkers(markers) // Inspection alone must not withdraw the offer. const { candidates, superseded } = derive(markers, 'may-be-held') retireSuperseded(superseded) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts index 45a0fa1997a..ef956f17cf8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -47,6 +47,7 @@ it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed'] as const)( it('publishes a real continuation through an overlapping audience exclusion', async () => { const { host, dispatch } = await interruptedRestart() await host.restartResume.list() + await host.revealSession(SESSION) const phases: (string | undefined)[] = [] const release = host.subscribeStatus({ id: 'overlap', @@ -113,6 +114,7 @@ it('publishes a real continuation through an overlapping audience exclusion', as it('publishes skipped when an offer disappears during reservation without starting its agent', async () => { const { host, acquire, dispatch } = await interruptedRestart() expect(await host.restartResume.list()).toHaveLength(1) + await host.revealSession(SESSION) vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'beginResume').mockResolvedValueOnce([]) const phases: (string | undefined)[] = [] const release = host.subscribeStatus({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts index e07b5ffff8b..59546845fe1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' @@ -134,7 +135,7 @@ describe('a provider stream cut short before its next lease renewal', () => { await streamBeforeCrash(workspaceKind) const cursor = (await rig.host.journalSnapshot(SESSION)).cursor await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) await expectSettledStream() const events: AgentSessionSubscribeEvent[] = [] @@ -158,11 +159,11 @@ describe('a provider stream cut short before its next lease renewal', () => { ]) unsubscribe() const settled = await rig.host.journalSnapshot(SESSION) - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) expect(await rig.host.journalSnapshot(SESSION)).toEqual(settled) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) await expectSettledStream() expect((await rig.host.journalSnapshot(SESSION)).items).toEqual(settled.items) } @@ -187,7 +188,7 @@ describe('a provider stream cut short before its next lease renewal', () => { rig.clock.now = LAST_CHUNK_AT await rig.host.flushAllStreamedEvents({ trigger: 'quit' }) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) await expectSettledStream() expect(rig.adapter.closeSession).toHaveBeenCalledOnce() @@ -201,7 +202,7 @@ describe('a provider stream cut short before its next lease renewal', () => { outcome: 'indeterminate', reason: 'probe unavailable' }) - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) const snapshot = await rig.host.journalSnapshot(SESSION) const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) @@ -260,7 +261,7 @@ describe('a provider stream cut short before its next lease renewal', () => { { fence: 2, turnScope: TURN_SCOPE, observedAt: later } ) await restartAfterCrash() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) await expectSettledStream() }) 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 98346eb6d20..8e674205762 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 @@ -1,14 +1,7 @@ -// Restore addressability from persisted identity without starting a child; failed journal opens retain a Retry tab. +// Open a chat on demand without starting a child; failed journal opens retain a Retry tab. -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' -import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' -import { - createReaderReconcile, - reportEachFailureOnce -} from './structured-agent-session-restart-reconcile' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal @@ -37,46 +30,3 @@ export async function revealStructuredAgentSession( ...(openRefusal ? { openRefusal } : {}) } } - -/** Reconcile/recover then open readable journals; advisory reader bookkeeping never fails startup. */ -export function createStructuredAgentSessionHostRestore( - deps: StructuredAgentSessionHostDeps, - wiring: Omit< - ConstructorParameters[0], - 'openDeps' | 'reconcileAll' | 'reconcile' | 'resolveRecovery' - > & { - reconcileLeases: (sessionId: string) => Promise - reconcileRestartLeases: () => Promise - resolveRecovery: (sessionId: string) => Promise - } -): { - reconcileRestartLeases: () => Promise - restoreReadableSessions: (sessionIds?: readonly string[]) => Promise -} { - const { reconcileLeases, reconcileRestartLeases, resolveRecovery, ...rest } = wiring - const failures = reportEachFailureOnce(deps.logger) - const reconcile = createReaderReconcile(reconcileLeases, failures) - const reconcileStartup = createReaderReconcile(reconcileRestartLeases, failures) - const restorer = new StructuredAgentSessionReadableRestorer({ - openDeps: deps, - reconcileAll: reconcileStartup, - reconcile, - // Attach/send must retry recovery strictly before acting. - resolveRecovery: (sessionId) => - resolveRecovery(sessionId).then( - () => true, - (error: unknown) => { - failures.report(error) - return false - } - ), - ...rest - }) - const gate = new StructuredAgentSessionRestartRestoreGate() - return { - reconcileRestartLeases: async () => { - await reconcileStartup() - }, - restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.test.ts new file mode 100644 index 00000000000..82bd46c9946 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.test.ts @@ -0,0 +1,370 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import * as journalFactory from '../agent-session-journal/journal-store-factory' +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { + savedStructuredAgentSessionStatus, + StructuredAgentSessionSavedStatus +} from './structured-agent-session-saved-status' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + adapter, + attach, + hostTestState, + CALLER, + envelope, + seedApproval +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { + interruptedRestart, + QUEUED_BEFORE_QUIT +} from './structured-agent-session-restart-interruption-test-harness' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import { savedStatusContracts } from '../../../shared/agent-session-saved-status.test-fixture' + +afterEach(() => vi.restoreAllMocks()) + +it('maps saved turn edges and legacy markers without using rename time', () => { + for (const contract of savedStatusContracts) { + expect( + savedStructuredAgentSessionStatus(agentSessionConversationView(contract.record)) + ).toEqual(contract.summary) + } + const record = agentSessionConversationView({ ...agentSessionRecordFixture(), updatedAt: 99_000 }) + expect(savedStructuredAgentSessionStatus(record)).toMatchObject({ + status: null, + updatedAt: record.createdAt + }) + expect(savedStructuredAgentSessionStatus(record, 35)).toMatchObject({ + status: 'idle', + turnOutcome: 'cancellation', + statusStartedAt: 35 + }) + expect( + savedStructuredAgentSessionStatus({ + ...record, + lastTurn: { state: 'open', at: 40, prompt: 'hello' } + }) + ).toMatchObject({ + status: 'idle', + turnOutcome: 'cancellation', + statusStartedAt: 40, + updatedAt: 40, + latestPrompt: 'hello' + }) + expect( + savedStructuredAgentSessionStatus( + { ...record, lastTurn: { state: 'settled', outcome: 'success', at: 50, reply: 'finished' } }, + 35 + ) + ).toMatchObject({ + status: 'idle', + turnOutcome: 'success', + statusStartedAt: 50, + lastAssistantMessage: 'finished' + }) + expect( + savedStructuredAgentSessionStatus({ + ...record, + sleptAt: 60, + lastTurn: { state: 'open', at: 40 } + }) + ).toMatchObject({ sleptAt: 60 }) +}) + +it('seeds first desktop and phone snapshots without opening, writing or reacting', () => { + const record = agentSessionConversationView({ + ...agentSessionRecordFixture(), + lastTurn: { state: 'open', at: 40 } as const + }) + const reaction = vi.fn() + const write = vi.fn() + const sink = { publish: vi.fn(), forget: vi.fn() } + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map(), + getConversation: () => record, + now: () => 99_000, + logger: createStructuredAgentSessionLogger(), + onStatusChanged: reaction, + onAgentStarted: reaction, + onPublished: write, + statusSink: () => sink + }) + const events: AgentSessionStatusEvent[] = [] + feed.publishSavedGroup((publish) => publish(record)) + feed.subscribe({ id: 'first-client', emit: (event) => events.push(event) }) + expect(events).toContainEqual({ + type: 'snapshot', + sessions: [ + expect.objectContaining({ sessionId: record.sessionId, turnOutcome: 'cancellation' }) + ] + }) + expect(sink.publish).toHaveBeenCalledWith( + expect.objectContaining({ turnOutcome: 'cancellation' }), + { ...record.location, kind: 'structured-session', sessionId: record.sessionId } + ) + expect(reaction).not.toHaveBeenCalled() + expect(write).not.toHaveBeenCalled() +}) + +it('writes turn and attention edges, ignores streamed previews, and clears an empty context', async () => { + await attach() + const { store, log } = hostTestState() + const writer = new StructuredAgentSessionSavedStatus(() => ({ store, logger: log.logger })) + const transition = vi.spyOn(store, 'transitionHandoff') + const summary: AgentSessionStatusSummary = { + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + status: 'working', + latestPrompt: 'p'.repeat(400), + statusStartedAt: 10, + updatedAt: 99 + } + writer.observe(summary) + writer.observe({ ...summary, lastAssistantMessage: 'streamed', updatedAt: 100 }) + await writer.drain() + expect(transition).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.lastTurn).toEqual({ + state: 'open', + at: 10, + prompt: 'p'.repeat(280) + }) + writer.observe({ ...summary, status: 'attention', statusStartedAt: 11 }) + writer.observe({ + ...summary, + status: 'idle', + turnOutcome: 'cancellation', + statusStartedAt: 12, + lastAssistantMessage: 'r'.repeat(400) + }) + await writer.drain() + expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ + state: 'settled', + outcome: 'cancellation', + at: 12, + reply: 'r'.repeat(280) + }) + // Rewinds and repeated refused sends can settle to the same outcome at a different edge. + writer.observe({ ...summary, status: 'idle', turnOutcome: 'cancellation', statusStartedAt: 13 }) + await writer.drain() + expect(store.getRecord(SESSION)?.lastTurn?.at).toBe(13) + writer.observe({ ...summary, status: null, latestPrompt: '', statusStartedAt: undefined }) + await writer.drain() + expect(store.getRecord(SESSION)?.lastTurn).toBeUndefined() + expect(transition).toHaveBeenCalledTimes(5) + writer.observe({ ...summary, status: 'idle', turnOutcome: 'failure', statusStartedAt: 14 }) + writer.observe({ ...summary, status: 'idle', turnOutcome: 'failure', statusStartedAt: 15 }) + await writer.drain() + expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ outcome: 'failure', at: 15 }) + writer.observe({ ...summary, statusStartedAt: 16 }) + writer.observe({ ...summary, status: null, latestPrompt: '', statusStartedAt: undefined }) + await writer.drain() + expect(store.getRecord(SESSION)?.lastTurn).toBeUndefined() +}) + +it.each(['quit', 'stop', 'failure', 'provider-death'] as const)( + 'keeps the displayed %s verdict through graceful drain, saved first paint and demand open', + async (ending) => { + await attach() + const { host: original, store, acquire } = hostTestState() + await store.setSessionTabVisibility(SESSION, true) + const body = hostTestMessage('finish this after restart') + await original.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('provider event sink missing') + } + events.appendItem({ provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, body, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await original.flushStreamedEvents(SESSION) + await original.drainStatusWrites() + expect(store.getRecord(SESSION)?.lastTurn?.state).toBe('open') + if (ending === 'stop') { + await seedApproval() + await original.drainStatusWrites() + expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ + state: 'open', + prompt: 'finish this after restart' + }) + await original.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + } + if (ending === 'provider-death') { + const generation = (await acquire.mock.results.at(-1)?.value)?.acquisitionGeneration + const fence = store.getRecord(SESSION)?.lease.runtimeFence + if (!generation || fence === undefined) { + throw new Error('provider identity missing') + } + await original.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'provider crashed', + cause: 'unexpected-exit', + fence, + acquisitionGeneration: generation + }) + } else if (ending === 'failure' || ending === 'stop') { + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, + { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + outcome: ending === 'stop' ? 'cancellation' : 'failure' + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await original.flushStreamedEvents(SESSION) + } + await original.flushAllStreamedEvents() + await original.drainStatusWrites() + const intentional = ending === 'quit' || ending === 'stop' + const outcome = intentional ? 'cancellation' : ending === 'failure' ? 'failure' : 'interruption' + expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ state: 'settled', outcome }) + const restarted = new StructuredAgentSessionHost({ ...original.deps, adapter: adapter() }) + try { + const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal') + await restarted.reconcileRestartLeases() + await restarted.seedSessionTabs([SESSION]) + expect(openJournal).not.toHaveBeenCalled() + for (const open of [false, true]) { + if (open) { + await restarted.revealSession(SESSION) + await restarted.drainStatusWrites() + } + const summary = restarted.readStatusSummary(SESSION) + if (!summary?.status) { + throw new Error('restarted status missing') + } + expect(summary.turnOutcome).toBe(outcome) + const row = structuredAgentSessionAgentStatus({ ...summary, status: summary.status }) + expect(agentVerdictDisplayMark(row)).toBe(intentional ? 'interrupted' : 'failed') + } + expect(openJournal).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ state: 'settled', outcome }) + } finally { + await restarted.flushAllStreamedEvents() + } + } +) + +it('lists only indexed unopened chats, keeps legacy targets, and opens history on demand', async () => { + await attach() + const { host: original, store, acquire } = hostTestState() + await store.setSessionTabVisibility(SESSION, true) + const sink = acquire.mock.calls.at(-1)?.[0].events + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'finished', ordinal: 1 }, + { kind: 'turn', turnId: 'finished', state: 'completed', outcome: 'success', completedAt: 123 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await original.flushStreamedEvents(SESSION) + await original.flushAllStreamedEvents() + const reaction = vi.fn() + const host = new StructuredAgentSessionHost({ + ...original.deps, + adapter: adapter(), + onSessionStatusChanged: reaction + }) + try { + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lastTurn: { state: 'settled', outcome: 'success', at: 20 } + })) + const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal') + openJournal.mockClear() + const transition = vi.spyOn(store, 'transitionHandoff') + await host.seedSessionTabs([SESSION]) + expect(host.hasSession(SESSION)).toBe(false) + expect(host.listSessionTabs()).toEqual([expect.objectContaining({ sessionId: SESSION })]) + expect(host.readStatusSummary(SESSION)).toMatchObject({ + turnOutcome: 'success', + statusStartedAt: 20 + }) + expect(reaction).not.toHaveBeenCalled() + expect(transition).not.toHaveBeenCalled() + expect(openJournal).not.toHaveBeenCalled() + await host.setSessionTabVisibility(SESSION, false) + await host.seedSessionTabs() + expect(host.listSessionTabs()).toEqual([]) + await host.setSessionTabVisibility(SESSION, true) + expect(host.listSessionTabs()).toHaveLength(1) + const capsule = host.deps.recoveryCapsule + if (!capsule) { + throw new Error('the fixture needs a recovery capsule') + } + const markers = Promise.withResolvers() + vi.spyOn(capsule, 'list').mockReturnValueOnce(markers.promise) + const seeding = host.seedSessionTabs() + await host.setSessionTabVisibility(SESSION, false) + markers.resolve([]) + await seeding + expect(host.listSessionTabs()).toEqual([]) + await host.setSessionTabVisibility(SESSION, true) + // An older build's chat backfills only when it is actually opened. + await store.transitionHandoff(SESSION, ({ lastTurn: _turn, ...record }) => record) + await host.revealSession(SESSION) + await host.drainStatusWrites() + expect(openJournal).toHaveBeenCalledOnce() + expect(host.readStatusSummary(SESSION)).toMatchObject({ + turnOutcome: 'success', + statusStartedAt: 123 + }) + expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ + state: 'settled', + outcome: 'success', + at: 123 + }) + } finally { + await host.flushAllStreamedEvents() + } +}) + +it('keeps queued leftovers closed at startup and shows their paused cards on demand', async () => { + const { host, store } = await interruptedRestart('queued-cards') + await host.setSessionTabVisibility(SESSION, true) + await store.transitionHandoff(SESSION, ({ lastTurn: _lastTurn, ...record }) => record) + const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal') + openJournal.mockClear() + await host.seedSessionTabs([SESSION]) + expect(host.hasSession(SESSION)).toBe(false) + expect(host.readStatusSummary(SESSION)).toMatchObject({ + status: 'idle', + turnOutcome: 'cancellation' + }) + await host.restartResume.list() + await host.restartResume.listFailures() + expect(openJournal).not.toHaveBeenCalled() + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(history.ok).toBe(true) + expect(history.ok && history.page.queuedMessages).toMatchObject( + QUEUED_BEFORE_QUIT.map((text) => ({ state: 'waiting', body: hostTestMessage(text) })) + ) + expect( + host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.pauses() + ).toMatchObject([{ reason: 'restarted' }]) + expect(openJournal).toHaveBeenCalledOnce() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.ts new file mode 100644 index 00000000000..568069954e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.ts @@ -0,0 +1,195 @@ +import type { + StructuredAgentSessionStatusPublisher, + StructuredAgentSessionStatusSink +} from './structured-agent-session-status-ownership' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import { + AGENT_SESSION_TURN_PREVIEW_CHARS, + type AgentSessionLastTurn +} from '../../../shared/agent-session-last-turn' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StatusPublication } from './structured-agent-session-status-feed-deps' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { structuredSummaryConversationNaming } from './structured-agent-session-status-summary' +import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory' +import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' +import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' +import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' + +export function savedStructuredAgentSessionStatus( + record: AgentSessionConversationView, + markerAt?: number +): AgentSessionStatusSummary { + const turn = record.lastTurn + const at = turn?.at ?? markerAt + const outcome = + turn?.state === 'open' || (!turn && markerAt !== undefined) ? 'cancellation' : turn?.outcome + const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) + const launchDirectory = agentSessionPinnedLaunchDirectory(record) + return { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + agent: record.provider, + status: turn || markerAt !== undefined ? 'idle' : null, + latestPrompt: turn?.prompt ?? '', + runtimePermission: null, + ...(turn?.reply ? { lastAssistantMessage: turn.reply } : {}), + ...(outcome ? { turnOutcome: outcome } : {}), + ...(at !== undefined ? { statusStartedAt: at } : {}), + ...(model ? { model } : {}), + ...(isAgentChatPermissionMode(record.permissionMode) + ? { permissionMode: record.permissionMode } + : {}), + ...(launchDirectory ? { launchDirectory } : {}), + ...structuredSummaryConversationNaming(record, undefined), + ...(typeof record.sleptAt === 'number' ? { sleptAt: record.sleptAt } : {}), + updatedAt: at ?? record.createdAt + } +} + +/** Only turn edges write; streamed prose and synthetic startup rows never reach this observer. */ +export class StructuredAgentSessionSavedStatus { + private readonly edges = new Map() + private readonly pending = new Set>() + + constructor( + private readonly deps: () => Pick + ) {} + + observe = (summary: AgentSessionStatusSummary): void => { + const state = + summary.status === 'working' || summary.status === 'attention' ? 'open' : 'settled' + const outcome = state === 'settled' ? summary.turnOutcome : undefined + const at = summary.statusStartedAt + // Clearing or rewinding to an empty conversation removes the previous turn. + if (at === undefined && state === 'open') { + return + } + const key = JSON.stringify([state, outcome, at]) + if (this.edges.get(summary.sessionId) === key) { + return + } + this.edges.set(summary.sessionId, key) + const lastTurn: AgentSessionLastTurn | undefined = + at === undefined + ? undefined + : { + state, + at, + ...(outcome ? { outcome } : {}), + prompt: summary.latestPrompt.slice(0, AGENT_SESSION_TURN_PREVIEW_CHARS), + ...(summary.lastAssistantMessage + ? { reply: summary.lastAssistantMessage.slice(0, AGENT_SESSION_TURN_PREVIEW_CHARS) } + : {}) + } + const write = this.deps() + .store.transitionHandoff(summary.sessionId, (record) => { + const { lastTurn: _previous, ...rest } = record + return lastTurn ? { ...rest, lastTurn } : _previous ? rest : record + }) + .then( + () => {}, + (error: unknown) => { + if (this.edges.get(summary.sessionId) === key) { + this.edges.delete(summary.sessionId) + } + this.deps().logger.warn('saving chat status failed', { + scope: 'saved-status', + sessionId: summary.sessionId, + error + }) + } + ) + this.pending.add(write) + void write.finally(() => this.pending.delete(write)) + } + + async drain(): Promise { + while (this.pending.size) { + await Promise.all(this.pending) + } + } +} + +export type SavedStructuredStatusPublisher = ( + record: AgentSessionConversationView, + markerAt?: number +) => void +export type SavedStructuredStatusGroup = ( + visit: (publish: SavedStructuredStatusPublisher) => void +) => void + +export function createSavedStructuredStatusGroup( + isOpen: (id: string) => boolean, + published: Map, + sink: ( + summary: AgentSessionStatusSummary, + location: AgentSessionExecutionLocation, + publisher?: StructuredAgentSessionStatusPublisher + ) => void, + broadcast: (event: AgentSessionStatusEvent) => void, + readSink: () => StructuredAgentSessionStatusSink | undefined +): SavedStructuredStatusGroup { + const publish = ( + record: AgentSessionConversationView, + markerAt?: number, + publisher?: StructuredAgentSessionStatusPublisher + ): void => { + if (isOpen(record.sessionId) || published.has(record.sessionId)) { + return + } + const summary = savedStructuredAgentSessionStatus(record, markerAt) + published.set(record.sessionId, { summary, firstInputSubmissionKey: null }) + sink(summary, record.location, publisher) + broadcast({ type: 'status', session: summary }) + } + return (visit) => { + const sink = readSink() + const run = (publisher?: StructuredAgentSessionStatusPublisher): void => + visit((record, markerAt) => publish(record, markerAt, publisher)) + if (sink?.withSavedStatusGroup) { + sink.withSavedStatusGroup(run) + } else { + run(sink) + } + } +} + +export async function seedSavedStructuredSessionTabs( + deps: Pick & { + store: Pick< + AgentSessionRecordStore, + 'listVisibleSessionIds' | 'isSessionTabVisible' | 'getConversation' + > + }, + ids: readonly string[] | undefined, + publishGroup: SavedStructuredStatusGroup, + recover: (ids: readonly string[]) => Promise +): Promise { + const targets = ids ?? deps.store.listVisibleSessionIds() + await recover(targets) + const markers = new Map() + try { + const capsule = deps.recoveryCapsule + const now = deps.now?.() ?? Date.now() + const [pending, failed] = await Promise.all([capsule?.list(now), capsule?.listFailed(now)]) + for (const marker of [...(pending ?? []), ...(failed ?? []).map((entry) => entry.marker)]) { + markers.set(marker.sessionId, marker.recordedAt) + } + } catch (error) { + deps.logger.warn('reading saved chat recovery status failed', { scope: 'saved-status', error }) + } + publishGroup((publish) => { + for (const id of targets) { + const record = deps.store.getConversation(id) + if (record && deps.store.isSessionTabVisible(id)) { + publish(record, markers.get(id)) + } + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 4bece4db33d..34b441dbe6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { cp, rm } from 'node:fs/promises' @@ -143,7 +144,7 @@ it('settles it when a read reaches the chat before the startup reconcile, then r await host.history({ sessionId: SESSION, direction: 'tail' }) expect(await turnStates(host)).toEqual(['unverifiable']) await host.reconcileRestartLeases() - await host.restoreReadableSessions([SESSION]) + await openTestChats(host, [SESSION]) expect(await turnStates(host)).toEqual(['interrupted']) await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts index 92decbc35f1..400ed6d42ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { rm } from 'node:fs/promises' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -115,7 +116,12 @@ async function restartAfterDeath(): Promise { } async function reread() { - await host.restoreReadableSessions([SESSION]) + if (!host.hasSession(SESSION)) { + await host.flushAllStreamedEvents() + host = new StructuredAgentSessionHost(host.deps) + replaceHostTestState({ store: host.deps.store, host }) + } + await openTestChats(host, [SESSION]) const { items } = await host.journalSnapshot(SESSION) const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId const stopRows = items.filter( @@ -148,6 +154,7 @@ describe('the row a quit writes for the reply it cut', () => { expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId }) expect(readerErrors).toEqual([LEGACY_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: trigger }) + expect(host.readStatusSummary(SESSION)?.turnOutcome).toBe('cancellation') }) it('writes nothing for a turn the provider finished during the stop', async () => { @@ -197,6 +204,7 @@ describe('the row a restart writes for a reply its Orca died in', () => { }) expect(readerErrors).toEqual([LEGACY_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) + expect(host.readStatusSummary(SESSION)?.turnOutcome).toBe('interruption') }) it('names the update an unfinished quit began, never a crash', async () => { @@ -253,5 +261,6 @@ describe('an agent this Orca runs', () => { ) const { stopRows } = await reread() expect(stopRows).toEqual([]) + expect(host.readStatusSummary(SESSION)?.turnOutcome).toBe('interruption') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 8c23c50d08c..a906ccccd96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -103,7 +104,7 @@ describe('a turn its gone agent left running', () => { setStructuredAgentSessionHost(rig.host) expect(await workingTurnState()).toBe('interrupted') - await rig.host.restoreReadableSessions([SESSION]) + await openTestChats(rig.host, [SESSION]) expect(await workingTurnState()).toBe('interrupted') expect(rig.adapter.acquire).toHaveBeenCalledOnce() }) 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 124c69f4221..5a23edf591e 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 @@ -149,14 +149,14 @@ it('adjudicates records a newer Orca wrote in memory only, and writes nothing', expect(await readdir(stateDirectory)).toEqual(files) }) -it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { +it('lists a chat without opening it while the reconcile keeps failing, and reports it once', async () => { const { host, store, leaseReconcileLogged } = await relaunch() writes.failing = true await host.reconcileRestartLeases() - await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + await expect(host.seedSessionTabs([SESSION])).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(true) + expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) expect(leaseReconcileLogged).toHaveBeenCalledOnce() expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) @@ -184,14 +184,14 @@ it('opens no chat from records a newer Orca wrote, and writes nothing trying', a const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) - await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + await expect(host.seedSessionTabs([SESSION])).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(false) expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(await readFile(path)).toEqual(bytes) }) -it('restores a chat for reading when resolving its recovery cannot write the store', async () => { +it('keeps a chat readable when recovery cannot write the store', async () => { const { host, store, leaseReconcileLogged } = await relaunch(false, async () => ({ outcome: 'indeterminate', reason: 'probe' @@ -200,17 +200,16 @@ it('restores a chat for reading when resolving its recovery cannot write the sto expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') writes.failing = true - await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + await expect(host.seedSessionTabs([SESSION])).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(true) + expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - expect(leaseReconcileLogged).toHaveBeenCalledOnce() - expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) + expect(leaseReconcileLogged).toHaveBeenCalledExactlyOnceWith(IO_ERROR) + expect(await host.revealSession(SESSION)).toMatchObject({ readable: true }) }) it.each([ - ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()], - ['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])] + ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()] ])('keeps the %s resolving when the logger throws', async (_step, read) => { const { host, leaseReconcileLogged } = await relaunch() const sinkError = new Error('error sink failed') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts index d06b2f7dd6e..6b57e7111e0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts @@ -42,6 +42,7 @@ export type StructuredAgentSessionStatusFeedDeps = { summary: AgentSessionStatusSummary, options: StructuredAgentSessionStatusObserverOptions ) => void + onPublished?: (summary: AgentSessionStatusSummary) => void /** Resolve after host field initialization; read child records from the sink holding their parent row. */ statusSink?: () => StructuredAgentSessionStatusSink | undefined /** Republish changed child records; producer identifies their write path. */ @@ -109,3 +110,18 @@ export function notifyStructuredStatusObservers( }) } } + +export function notifyStructuredStatusPublication( + deps: StructuredAgentSessionStatusFeedDeps, + summary: AgentSessionStatusSummary +): void { + try { + deps.onPublished?.(summary) + } catch (error) { + deps.logger.warn('saving chat status failed', { + scope: 'saved-status', + sessionId: summary.sessionId, + error + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 200a365fec3..b10f958348b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -1,9 +1,10 @@ -// Project host journals for all session lists, retaining settled rows across idle eviction and renderer reload. +// Retain saved and live chat summaries across idle eviction and renderer reload. import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { createSavedStructuredStatusGroup } from './structured-agent-session-saved-status' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -11,6 +12,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import { structuredAgentSessionNamingSnapshot, notifyStructuredStatusObservers, + notifyStructuredStatusPublication, type StatusFeedSession, type StatusPublication, type StructuredAgentSessionStatusSubscriber, @@ -18,6 +20,7 @@ import { } from './structured-agent-session-status-feed-deps' import { renamedStructuredStatusSummary, + releasedStructuredStatusSummary, retainedStructuredStatusSummaries, structuredAgentSessionStatusSummary } from './structured-agent-session-status-summary' @@ -33,6 +36,7 @@ import { import type { StructuredAgentSessionChildWorkProducer } from './structured-agent-session-subagent-rows' import { StructuredAgentSessionStatusOwnership, + type StructuredAgentSessionStatusPublisher, type CapturedChildWorkStops } from './structured-agent-session-status-ownership' @@ -125,14 +129,7 @@ export class StructuredAgentSessionStatusFeed { if (!previous) { return } - const { - hostExecutionOwned: _hostExecutionOwned, - hostExecutionPhase: _hostExecutionPhase, - stopping: _stopping, - restartResume: _restartResume, - ...retained - } = previous - retained.runtimePermission = null + const retained = releasedStructuredStatusSummary(previous) this.published.set(sessionId, { summary: retained, firstInputSubmissionKey: publication?.firstInputSubmissionKey ?? null @@ -164,6 +161,15 @@ export class StructuredAgentSessionStatusFeed { readPublished = (sessionId: string): AgentSessionStatusSummary | undefined => this.published.get(sessionId)?.summary + /** Seed unopened lists and the phone sink without reactions or saved-status writes. */ + publishSavedGroup = createSavedStructuredStatusGroup( + (id) => this.deps.sessions.has(id), + this.published, + this.sink.bind(this), + this.broadcast.bind(this), + () => this.deps.statusSink?.() + ) + /** Share per-commit projection with completion and steer-hold readers to avoid repeated snapshots. */ journalProjection( sessionId: string, @@ -197,6 +203,7 @@ export class StructuredAgentSessionStatusFeed { namingFailure: this.deps.namingFailure?.(sessionId), now: this.deps.now }) + notifyStructuredStatusPublication(this.deps, summary) const publication = this.published.get(sessionId) const previous = publication?.summary const summaryChanged = !previous || !structuredStatusSummariesEqual(previous, summary) @@ -312,10 +319,11 @@ export class StructuredAgentSessionStatusFeed { private sink( summary: AgentSessionStatusSummary, - location?: StatusFeedSession['params']['location'] + location?: StatusFeedSession['params']['location'], + publisher?: StructuredAgentSessionStatusPublisher ): void { try { - this.ownership.publish(summary, location) + this.ownership.publish(summary, location, publisher) } catch (error) { this.logFailure('status-sink-publish', 'status sink publish failed', summary.sessionId, error) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index bd2f042f00b..b9cf744c5fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -16,12 +16,16 @@ import { type ChildWorkLifetimeToken } from './structured-agent-session-open-child-work' -export type StructuredAgentSessionStatusSink = { +export type StructuredAgentSessionStatusPublisher = { publish: ( summary: AgentSessionStatusSummary, subject: AgentStatusStructuredSessionSubject ) => void forget: (subject: AgentStatusStructuredSessionSubject) => void +} + +export type StructuredAgentSessionStatusSink = StructuredAgentSessionStatusPublisher & { + withSavedStatusGroup?: (visit: (sink: StructuredAgentSessionStatusPublisher) => void) => void /** Address evidence by the parent row’s landed subject. */ publishChildWork?: ( subject: AgentStatusStructuredSessionSubject, @@ -59,8 +63,11 @@ export class StructuredAgentSessionStatusOwnership { ) } - publish(summary: AgentSessionStatusSummary, location?: AgentSessionExecutionLocation): void { - const sink = this.sink() + publish( + summary: AgentSessionStatusSummary, + location?: AgentSessionExecutionLocation, + sink: StructuredAgentSessionStatusPublisher | undefined = this.sink() + ): void { if (!sink || (!location && !this.subjects.has(summary.sessionId))) { return } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 1cccb5c6aa0..223aa507315 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -127,3 +127,16 @@ export function retainedStructuredStatusSummaries( ({ summary }) => renamedStructuredStatusSummary(summary, deps, summary.sessionId) ?? summary ) } + +export function releasedStructuredStatusSummary( + previous: AgentSessionStatusSummary +): AgentSessionStatusSummary { + const { + hostExecutionOwned: _hostExecutionOwned, + hostExecutionPhase: _hostExecutionPhase, + stopping: _stopping, + restartResume: _restartResume, + ...retained + } = previous + return { ...retained, runtimePermission: null } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts index 6e0e7b7772d..0e4ec8d7c1a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts @@ -53,7 +53,9 @@ async function relaunchWith( filter: (source) => !source.includes('.lock') }) edit(openTestJournalHostDatabase(relaunched).db) - const store = await openTestAgentSessionRecordStore(relaunched) + const store = await openTestAgentSessionRecordStore(relaunched, { + legacySessionTabIds: [SESSION] + }) const host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -85,7 +87,7 @@ it.each([ const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId const refusal = { code: 'agent_session_journal_unreadable', details: { reason } } - await host.restoreReadableSessions([SESSION]) + await host.seedSessionTabs([SESSION]) expect(host.hasSession(SESSION)).toBe(false) expect(host.listSessionTabs()).toEqual([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 6e341c42295..1440d1f3d76 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -659,7 +660,7 @@ describe("the reopen's mark", () => { void working const sent = rig.dispatch.mock.calls.length rig.crashRestartHostProcess() - await rig.host.restoreReadableSessions([SESSION]) + await openTestChats(rig.host, [SESSION]) expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) await observationClock.observe(100) expect(await rig.handoff(queued.id)).toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 5d3ef6644ec..fda6e23dae0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Real wedged record shapes must become acquirable while retaining conversation and provider history. // Migration releases the lease; only operations needing the agent may spawn it. @@ -112,7 +113,7 @@ function wedgedRecord(overrides: WedgeOverrides): PersistedAgentSessionRecord { async function seedStore(record: PersistedAgentSessionRecord): Promise { await seedTestAgentSessionRecordStore(root, { records: [record] }) - store = await openTestAgentSessionRecordStore(root) + store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] }) } function openHost(overrides: Partial = {}): void { @@ -264,7 +265,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn(provider) openHost() - await host.restoreReadableSessions() + await openTestChats(host) expect(host.hasSession(SESSION)).toBe(true) const firstCursor = restoredJournal().cursor() @@ -276,9 +277,9 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).not.toHaveBeenCalled() await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) + store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] }) openHost() - await host.restoreReadableSessions() + await openTestChats(host) expect(restoredJournal().cursor()).toEqual(firstCursor) expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -332,7 +333,7 @@ describe('already-wedged profiles become usable on load', () => { : {}) }) - await host.restoreReadableSessions() + await openTestChats(host) expect(acquire).not.toHaveBeenCalled() expect(turnLifecycle('turn-1')).toEqual({ @@ -355,8 +356,9 @@ describe('already-wedged profiles become usable on load', () => { expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) expect(published.map((summary) => summary.status)).not.toContain('working') // Crash evidence must never become a user-attributed cancellation. - expect(published.map((summary) => summary.turnOutcome)).toEqual( - published.map(() => (verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed')) + expect(published[0]?.turnOutcome).toBeUndefined() // Legacy list is neutral until opened. + expect(published.at(-1)?.turnOutcome).toBe( + verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed' ) } ) @@ -452,7 +454,7 @@ describe('already-wedged profiles become usable on load', () => { const failing = vi .spyOn(JournalLifecycleBatchAppender.prototype, 'appendPlanned') .mockRejectedValue(new Error('journal unavailable')) - await host.restoreReadableSessions() + await openTestChats(host) failing.mockRestore() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe('turn-1') expect(stopOwnerProcess).toHaveBeenCalledTimes(ownerOutlivedRestart ? 1 : 0) @@ -493,7 +495,7 @@ describe('already-wedged profiles become usable on load', () => { resolveRecovery({ ...deps, delay: async () => {} }, sessionId) ) try { - await host.restoreReadableSessions() + await openTestChats(host) } finally { recovery.mockRestore() } @@ -524,7 +526,7 @@ describe('already-wedged profiles become usable on load', () => { await seedStore(wedgedRecord({ handoffStage: 'manual-recovery', ...lease })) openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no scan here' }) }) - await host.restoreReadableSessions() + await openTestChats(host) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -592,7 +594,7 @@ describe('already-wedged profiles become usable on load', () => { ) openHost() - await host.restoreReadableSessions() + await openTestChats(host) const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -611,7 +613,7 @@ describe('already-wedged profiles become usable on load', () => { await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' })) openHost() - await host.restoreReadableSessions() + await openTestChats(host) const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -640,7 +642,7 @@ describe('already-wedged profiles become usable on load', () => { } }) - await host.restoreReadableSessions() + await openTestChats(host) const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -682,7 +684,7 @@ describe('already-wedged profiles become usable on load', () => { stopOwnerProcess: (pid) => order.push(`stop:${pid}`) }) - await host.restoreReadableSessions() + await openTestChats(host) expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null @@ -702,7 +704,7 @@ describe('already-wedged profiles become usable on load', () => { const stopOwnerProcess = vi.fn() let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } openHost({ probeOwner: async () => probe, stopOwnerProcess }) - await host.restoreReadableSessions() + await openTestChats(host) // An unanswered probe cannot authorize takeover of a TUI transport surviving restart. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 654c9510fd2..022283399aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { @@ -156,7 +157,7 @@ it.each(['persist', 'root-exit', 'retry', 'successor', 'root-successor'] as cons kind: 'exit-observed', ownerFence: 1 }) - await restarted.restoreReadableSessions() + await openTestChats(restarted) expect(await restarted.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: ['session-alpha'] }) @@ -379,7 +380,7 @@ it('sleeps only proven ended chats after idle eviction or restart', async () => expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) expect(rig.store.getRecord(SESSION)?.sleptAt).toBeUndefined() await rig.restart() - await rig.host.restoreReadableSessions() + await openTestChats(rig.host) expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) const getRecord = rig.store.getRecord.bind(rig.store) vi.spyOn(rig.store, 'getRecord').mockImplementation((id) => { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index ae352e32c4a..1daa10e4f4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from './structured-agent-session-open-test-chats.test-support' import { listStructuredProviderSessionOwnership } from './structured-provider-session-ownership' import { findConflictingStructuredAdoption } from '../structured-agent-session-history-adoption' import { cp, mkdtemp, rm } from 'node:fs/promises' @@ -333,7 +334,7 @@ describe('same-conversation clear', () => { it('starts fresh after a restart between clear and first send', async () => { await clearCommits() await restartHost() - await host.restoreReadableSessions(store.listVisibleSessionIds()) + await openTestChats(host, store.listVisibleSessionIds()) expect(await host.send(caller, sendParams('after restart'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) expect(vi.mocked(adapter.acquire).mock.calls.at(-1)![0].identity.providerHandle).toBeNull() @@ -371,7 +372,7 @@ describe('same-conversation clear', () => { replacementSessionId: 'old-clear-destination' } }) - await host.restoreReadableSessions(store.listVisibleSessionIds()) + await openTestChats(host, store.listVisibleSessionIds()) expect(await host.send(caller, sendParams('still this conversation'))).toMatchObject({ ok: true }) diff --git a/src/main/runtime/agent-session-founding-admission.ts b/src/main/runtime/agent-session-founding-admission.ts index 2574394e12c..b76c91e8191 100644 --- a/src/main/runtime/agent-session-founding-admission.ts +++ b/src/main/runtime/agent-session-founding-admission.ts @@ -24,7 +24,8 @@ import { setAgentSessionTabVisibility } from './agent-session/agent-session-tab- /** Use create admission without requiring owner acquisition. */ export function commitAgentSessionFounding( state: AgentSessionStoreState, - request: AgentSessionReserveRequest + request: AgentSessionReserveRequest, + legacyIds: readonly string[] = [] ): AgentSessionReserveResult { const decision = evaluateAgentSessionReserveOperation(state, request) if (decision.decision === 'refused') { @@ -61,7 +62,7 @@ export function commitAgentSessionFounding( } throw error } - assertReservedTabUnheld(state, request) + assertReservedTabUnheld(state, request, legacyIds) const founded = foundAgentSessionRecord(request, request) const record = request.adoptedHandleLink ? { ...founded, providerHandleChain: [request.adoptedHandleLink] } @@ -75,6 +76,6 @@ export function commitAgentSessionFounding( agentSessionOperationKey(operationRow.callerKey, operationRow.operationId), operationRow ) - setAgentSessionTabVisibility(state, request.sessionId, true, request.surfaceTabId) + setAgentSessionTabVisibility(state, request.sessionId, true, request.surfaceTabId, legacyIds) return { record, disposition: 'created', operationRow } } diff --git a/src/main/runtime/agent-session-founding-store.ts b/src/main/runtime/agent-session-founding-store.ts index 2561e8d14eb..90966dc60ee 100644 --- a/src/main/runtime/agent-session-founding-store.ts +++ b/src/main/runtime/agent-session-founding-store.ts @@ -12,12 +12,16 @@ export function createAgentSessionFoundingStore( ) { return { prepare: (request: AgentSessionReserveRequest) => - commitAgentSessionFounding(draftAgentSessionStoreState(transactions.state), request), + commitAgentSessionFounding( + draftAgentSessionStoreState(transactions.state), + request, + transactions.legacySessionTabIds + ), commit: async (request: AgentSessionReserveRequest) => { let heldBefore = true const result = await transactions.transact((draft) => { heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0 - return commitAgentSessionFounding(draft, request) + return commitAgentSessionFounding(draft, request, transactions.legacySessionTabIds) }) if (!heldBefore) { firstRecordListeners.forEach((listener) => listener()) @@ -28,7 +32,7 @@ export function createAgentSessionFoundingStore( let heldBefore = true const receipt = transactions.receipt((draft) => { heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0 - const result = commitAgentSessionFounding(draft, request) + const result = commitAgentSessionFounding(draft, request, transactions.legacySessionTabIds) if (result.disposition === 'replayed') { throw agentSessionRefusalError('agent_session_operation_unknown', { reason: 'outcomeUnknown' diff --git a/src/main/runtime/agent-session-inventory-scale.test.ts b/src/main/runtime/agent-session-inventory-scale.test.ts index 2a4b6d6dad8..c0b5cc4f9e6 100644 --- a/src/main/runtime/agent-session-inventory-scale.test.ts +++ b/src/main/runtime/agent-session-inventory-scale.test.ts @@ -10,12 +10,11 @@ import { import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { StructuredAgentSessionReadableRestorer } from '../native-chat/agent-session-wire/structured-agent-session-readable-restorer' +import * as journalFactory from '../native-chat/agent-session-journal/journal-store-factory' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { countRecordQueries, countStoreWork, - journals, scaleJournal, scaleStore, scaleDirectory, @@ -299,6 +298,7 @@ describe('real conversation inventory work', () => { sessionTabs: [{ tabId: 'cold-tab', sessionId: successor }] }) const opened = await openAgentSessionRecordStoreOnce({ + legacySessionTabIds: [], stateDirectory: root, hostId: 'local', logger: createStructuredAgentSessionLogger() @@ -480,7 +480,7 @@ describe('real conversation inventory work', () => { }) it.each([10, 100])( - 'restores selected journals and scopes lifecycle/audience inventory at %i rows', + 'seeds selected records without opening journals and scopes lifecycle inventory at %i rows', async (n) => { const rig = await inventoryRig(n) for (const id of ['damaged-chat', rig.rows[0]!.sessionId, rig.rows[1]!.sessionId]) { @@ -488,29 +488,11 @@ describe('real conversation inventory work', () => { } const work = countStoreWork(rig.store) const queries = countRecordQueries(rig.root) - const restored: string[] = [] - const restorer = new StructuredAgentSessionReadableRestorer({ - openDeps: { - store: rig.store, - journalDatabase: openTestJournalHostDatabase(rig.root), - logger: rig.host.deps.logger - }, - reconcileAll: async () => false, - reconcile: async () => false, - resolveRecovery: async () => false, - serialize: async (_id, task) => task(), - hasSession: () => false, - onReadable: (id, opened) => { - restored.push(id) - journals.track(opened.session.journal) - } - }) - await restorer.restore(['damaged-chat', rig.rows[0]!.sessionId]) - expect(restored).toEqual(['damaged-chat', rig.rows[0]!.sessionId]) - expect(work.counts).toMatchObject({ inventories: 1, views: n + 2, keyed: 2, clones: 0 }) - const restoredWork = { ...work.counts } - await restorer.restore() - expect(work.counts).toEqual(restoredWork) + const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal') + await rig.host.seedSessionTabs(['damaged-chat', rig.rows[0]!.sessionId]) + expect(openJournal).not.toHaveBeenCalled() + expect(rig.host.hasSession(rig.rows[0]!.sessionId)).toBe(false) + expect(work.counts).toMatchObject({ inventories: 0, views: 1, keyed: 2, clones: 0 }) work.reset() const scoped = listStructuredSessionsForWorktree('workspace-1', { resolvedConnectionId: null diff --git a/src/main/runtime/agent-session-record-store-slot.test.ts b/src/main/runtime/agent-session-record-store-slot.test.ts index 344f885f3c9..85b33f29318 100644 --- a/src/main/runtime/agent-session-record-store-slot.test.ts +++ b/src/main/runtime/agent-session-record-store-slot.test.ts @@ -18,6 +18,7 @@ let stateDirectory: string function location(directory = stateDirectory) { return { stateDirectory: directory, + legacySessionTabIds: [], hostId: HOST_ID, logger: createStructuredAgentSessionLogger() } diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts index f72d4c589f2..9d46f57d59c 100644 --- a/src/main/runtime/agent-session-record-store-slot.ts +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -13,6 +13,7 @@ export type AgentSessionRecordStoreLocation = { stateDirectory: string hostId: string logger: StructuredAgentSessionLogger + legacySessionTabIds: readonly string[] } export type OpenedAgentSessionRecordStore = { @@ -70,7 +71,11 @@ async function openRecordStore( try { return { journalDatabase, - store: AgentSessionRecordStore.open({ journalDatabase, hostId: location.hostId }) + store: AgentSessionRecordStore.open({ + journalDatabase, + hostId: location.hostId, + legacySessionTabIds: location.legacySessionTabIds + }) } } catch (error) { journalDatabase.close() diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index f8261179fe4..41e377c24f5 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -35,11 +35,12 @@ function databaseFor(stateDirectory: string): Database.Database { export async function openTestAgentSessionRecordStore( stateDirectory: string, - options: { hostId?: string } = {} + options: { hostId?: string; legacySessionTabIds?: readonly string[] } = {} ): Promise { return AgentSessionRecordStore.open({ journalDatabase: openTestJournalHostDatabase(stateDirectory), - hostId: options.hostId ?? TEST_HOST_ID + hostId: options.hostId ?? TEST_HOST_ID, + legacySessionTabIds: options.legacySessionTabIds ?? [] }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 13bb3da1566..b32208de2b8 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -65,12 +65,7 @@ import { heldAgentSessionIds, type AgentSessionStoreState } from './agent-session-store-state' -import { - agentSessionVisibleTabIndex, - listVisibleAgentSessionIds, - setAgentSessionTabVisibility, - showAgentSessionTabs -} from './agent-session/agent-session-tab-table' +import { AgentSessionRecordTabs } from './agent-session/agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { agentSessionOperationOutcomeReceipt } from './agent-session-operation-receipt' @@ -84,14 +79,13 @@ import { setAgentSessionRecordConversationName, setAgentSessionRecordUserNameThrough } from './agent-session-record-conversation-name' -import { AgentSessionRecordPublication } from './agent-session-record-publication' /** Single-writer records/operation ledger in the host chat-journal database. */ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 -export class AgentSessionRecordStore extends AgentSessionRecordPublication { +export class AgentSessionRecordStore extends AgentSessionRecordTabs { readonly conversationReceipts: ReturnType private constructor( @@ -107,9 +101,15 @@ export class AgentSessionRecordStore extends AgentSessionRecordPublication { static open(args: { journalDatabase: JournalHostDatabase hostId: string + legacySessionTabIds: readonly string[] }): AgentSessionRecordStore { const rows = loadAgentSessionStoreRows(args.journalDatabase.db) - const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows, args.hostId) + const transactions = new AgentSessionStoreTransactions( + args.journalDatabase, + rows, + args.hostId, + args.legacySessionTabIds + ) return new AgentSessionRecordStore(transactions, args.hostId) } @@ -146,52 +146,6 @@ export class AgentSessionRecordStore extends AgentSessionRecordPublication { /** Record presence is permanent, including unreadable rows. */ holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0 - listVisibleSessionIds = (): string[] => listVisibleAgentSessionIds(this.state) - - getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => - agentSessionVisibleTabIndex(this.state) - - getSessionTabId = (sessionId: string): string | null => - this.state.sessionTabs?.tabIdFor(sessionId) ?? null - - /** Persist chat-tab identity separately from rollback-sensitive profile tabs; explicit tabId restores hidden tabs under their old ids. */ - setSessionTabVisibility( - sessionId: string, - visible: boolean, - tabId?: string, - check?: () => void - ): Promise { - return this.transact((draft) => { - check?.() - return setAgentSessionTabVisibility(draft, sessionId, visible, tabId) - }) - } - - setSwitchedSessionTabVisibility( - sessionId: string, - tabId: string, - visible: boolean, - admit: () => boolean - ): Promise { - return this.transact((draft) => { - if ( - (visible - ? draft.sessionTabs?.tabIdFor(sessionId) !== undefined - : draft.sessionTabs?.tabIdFor(sessionId) !== tabId) || - !admit() - ) { - return false - } - setAgentSessionTabVisibility(draft, sessionId, visible, tabId) - return true - }) - } - - /** Publish the complete tab index in one write; a partial index would drop remaining chats on restart. */ - showSessionTabs(sessionIds: readonly string[]): Promise { - return this.transact((draft) => showAgentSessionTabs(draft, sessionIds)) - } - listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { const scope = agentSessionScopeKey(location) return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope) @@ -233,7 +187,12 @@ export class AgentSessionRecordStore extends AgentSessionRecordPublication { ): Promise => this.transact((draft) => { check?.() - return commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS) + return commitAgentSessionReservation( + draft, + request, + AGENT_SESSION_LEASE_TTL_MS, + this.transactions.legacySessionTabIds + ) }) readonly founding: ReturnType diff --git a/src/main/runtime/agent-session-saved-roster.test-support.ts b/src/main/runtime/agent-session-saved-roster.test-support.ts new file mode 100644 index 00000000000..7b67913dea3 --- /dev/null +++ b/src/main/runtime/agent-session-saved-roster.test-support.ts @@ -0,0 +1,108 @@ +import { vi, type Mock, type MockInstance } from 'vitest' +import { AgentHookServer } from '../agent-hooks/server' +import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps' +import { AgentStatusObservedPaneIdentities } from './agent-status-observed-pane-identity' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { scaleStore } from './agent-session-scale.test-fixture' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { AgentStatusStore } from '../../shared/agent-status-store' + +class CountedServer extends AgentHookServer { + builds = 0 + storeForContract() { + return this.canonicalStatusStore + } + listingForContract() { + return this.canonicalListingOrder + } + subjectsForContract() { + return this.canonicalSubjectsByPane + } + protected override buildStatusChangeNotification() { + this.builds++ + return super.buildStatusChangeNotification() + } +} + +export async function savedRosterRig(n: number) { + const rig = await scaleStore(n) + const ids = rig.rows.slice(0, n).map((row) => row.sessionId) + for (const id of ids) { + await rig.store.transitionHandoff(id, (record) => ({ + ...record, + lastTurn: { state: 'settled', outcome: 'success', at: 100 }, + lease: { + ...record.lease, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + handoffStage: null, + unreconciled: false + } + })) + } + const server = new CountedServer() + const sink = agentHookRuntimeDeps( + server, + new AgentStatusObservedPaneIdentities(), + () => ({}) + ).structuredAgentStatusSink + const host = new StructuredAgentSessionHost({ + ...hostTestState().host.deps, + store: rig.store, + recoveryCapsule: undefined, + statusSink: sink + }) + const location = rig.rows[0]!.location + function live(id = 'live-session', status: AgentSessionStatusSummary['status'] = 'working') { + server.ingestStructuredStatus( + { + sessionId: id, + workspaceId: location.workspaceId, + agent: 'claude', + status, + latestPrompt: 'live input', + providerSession: { key: 'session_id', id: `provider-${id}` }, + statusStartedAt: 200, + updatedAt: 200 + }, + makeStructuredAgentStatusSubject(location, id) + ) + } + return { ...rig, ids, server, sink, host, live } +} + +type RosterWork = { + counts: { parentVisits: number; enumerations: number } + revision: MockInstance + mutation: MockInstance + snapshots: MockInstance + listing: MockInstance['set']> + subjects: MockInstance['set']> + rows: Mock[0]> + enriched: Mock[0]> +} + +export function countRosterWork(server: CountedServer): RosterWork { + const store = server.storeForContract() + const counts = { parentVisits: 0, enumerations: 0 } + const parents = store.getParents + vi.spyOn(store, 'getParents').mockImplementation(() => { + const rows = parents() + counts.enumerations++ + counts.parentVisits += rows.length + return rows + }) + return { + counts, + revision: vi.spyOn(store, 'getRevision'), + mutation: vi.spyOn(store, 'applyMutation'), + snapshots: vi.spyOn(store, 'getSnapshot'), + listing: vi.spyOn(server.listingForContract(), 'set'), + subjects: vi.spyOn(server.subjectsForContract(), 'set'), + rows: vi.fn[0]>(), + enriched: vi.fn[0]>() + } +} diff --git a/src/main/runtime/agent-session-saved-roster.test.ts b/src/main/runtime/agent-session-saved-roster.test.ts new file mode 100644 index 00000000000..7de88f86e08 --- /dev/null +++ b/src/main/runtime/agent-session-saved-roster.test.ts @@ -0,0 +1,290 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { savedRosterRig, countRosterWork } from './agent-session-saved-roster.test-support' +import * as journalFactory from '../native-chat/agent-session-journal/journal-store-factory' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import type { AgentSessionStatusEvent } from '../../shared/agent-session-wire' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +afterEach(() => vi.restoreAllMocks()) + +it.each([ + [10, 0], + [100, 0], + [10, 10], + [10, 100] +])( + 'seeds %i canonical saved rows with %i existing rows, building only the completed roster', + async (n, existing) => { + const { host, server, store, ids, live } = await savedRosterRig(n) + for (let i = 0; i < existing; i++) { + live(`existing-${i}`) + } + server.builds = 0 + const work = countRosterWork(server) + server.subscribeStatusRowMutations(work.rows) + server.subscribeEnrichedStatus(work.enriched) + const rosters = vi.fn() + const providers = vi.fn() + server.subscribeStatusChanges(rosters) + server.subscribeProviderSessionChanges(providers) + const events: AgentSessionStatusEvent[] = [] + host.subscribeStatus({ id: 'before-seed', emit: (event) => events.push(event) }) + const writes = vi.spyOn(store, 'transitionHandoff') + const journals = vi.spyOn(journalFactory, 'openAgentSessionJournal') + await host.seedSessionTabs(ids) + expect(journals).not.toHaveBeenCalled() + expect(writes).not.toHaveBeenCalled() + expect(work.revision).toHaveBeenCalledTimes(n) + expect(work.mutation).toHaveBeenCalledTimes(n) + expect(work.listing).toHaveBeenCalledTimes(n) + expect(work.subjects).toHaveBeenCalledTimes(n) + expect(work.rows).toHaveBeenCalledTimes(n) + expect(work.enriched).toHaveBeenCalledTimes(n) + expect(work.snapshots).not.toHaveBeenCalled() + expect(server.builds).toBe(1) + expect(work.counts).toEqual({ enumerations: 1, parentVisits: existing + n }) + const expectedPanes = [ + ...Array.from({ length: existing }, (_, i) => `existing-${i}`), + ...ids + ].map((id) => structuredAgentSessionPaneKey(structuredAgentSessionTabId(id), id)) + expect(rosters).toHaveBeenCalledExactlyOnceWith( + expectedPanes.map((paneKey, i) => ({ + paneKey, + state: i < existing ? 'working' : 'done', + receivedAt: expect.any(Number), + observedInCurrentRuntime: true + })) + ) + expect(providers).toHaveBeenCalledExactlyOnceWith( + expectedPanes.slice(0, existing).map((paneKey, i) => ({ + paneKey, + sessionId: `provider-existing-${i}`, + worktreeId: 'workspace-1' + })) + ) + expect(events.filter((event) => event.type === 'status')).toHaveLength(n) + for (const id of ids) { + expect( + server.getStatusSnapshotForPane( + structuredAgentSessionPaneKey(structuredAgentSessionTabId(id), id) + ) + ).toMatchObject([ + { + state: 'done', + interrupted: undefined, + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 100 } + } + ]) + expect(host.readStatusSummary(id)).toMatchObject({ + sessionId: id, + turnOutcome: 'success', + statusStartedAt: 100 + }) + } + const first = vi.fn() + host.subscribeStatus({ id: 'first-client', emit: first }) + expect(first).toHaveBeenCalledWith({ + type: 'snapshot', + sessions: ids.map((id) => host.readStatusSummary(id)) + }) + expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(existing + n) + await host.seedSessionTabs(ids) + await host.seedSessionTabs([]) + expect(rosters).toHaveBeenCalledTimes(1) + expect(server.builds).toBe(1) + await store.transitionHandoff('closed-chat', (record) => ({ + ...record, + lastTurn: { state: 'settled', outcome: 'cancellation', at: 300 } + })) + await host.setSessionTabVisibility('closed-chat', true) + await host.seedSessionTabs([...ids, 'closed-chat']) + expect(rosters).toHaveBeenCalledTimes(2) + expect(rosters.mock.calls[1]?.[0]).toHaveLength(existing + n + 1) + expect(host.readStatusSummary('closed-chat')).toMatchObject({ + turnOutcome: 'cancellation', + statusStartedAt: 300 + }) + } +) + +it.each(['recovery', 'capsule'] as const)( + 'leaves live roster, row and enriched delivery immediate while %s is pending', + async (phase) => { + const rig = await savedRosterRig(1) + const hold = Promise.withResolvers() + const entered = Promise.withResolvers() + const capsule = new AgentSessionRecoveryCapsule(rig.root) + const probe = vi.fn(async (): Promise => { + entered.resolve() + await hold.promise + return { outcome: 'indeterminate', reason: 'fixture held' } + }) + if (phase === 'recovery') { + await rig.store.transitionHandoff(rig.ids[0]!, (record) => ({ + ...record, + lease: { ...rig.rows[0]!.lease, unreconciled: true } + })) + } else { + vi.spyOn(capsule, 'list').mockImplementation(async () => { + entered.resolve() + await hold.promise + return [] + }) + } + const host = new StructuredAgentSessionHost({ + ...rig.host.deps, + probeOwner: probe, + recoveryCapsule: capsule + }) + const roster = vi.fn(), + rows = vi.fn(), + enriched = vi.fn() + rig.server.subscribeStatusChanges(roster) + rig.server.subscribeStatusRowMutations(rows) + rig.server.subscribeEnrichedStatus(enriched) + const pending = host.seedSessionTabs(rig.ids) + await entered.promise + rig.live() + expect(roster).toHaveBeenCalledTimes(1) + expect(rows).toHaveBeenCalledTimes(1) + expect(enriched).toHaveBeenCalledTimes(1) + expect(host.readStatusSummary(rig.ids[0]!)).toBeUndefined() + hold.resolve() + await pending + expect(roster).toHaveBeenCalledTimes(2) + if (phase === 'capsule') { + vi.spyOn(capsule, 'listFailed').mockRejectedValueOnce(new Error('advisory capsule failed')) + await host.setSessionTabVisibility('closed-chat', true) + await host.seedSessionTabs(['closed-chat']) + expect(host.readStatusSummary('closed-chat')).toBeDefined() + } + } +) + +it.each(['row', 'enriched'] as const)( + 'publishes a reentrant live edge immediately from a saved %s observer then flushes later rows', + async (observer) => { + const { host, ids, server, live } = await savedRosterRig(2) + const sizes: number[] = [] + server.subscribeStatusChanges((rows) => sizes.push(rows.length)) + let entered = false + const reenter = () => { + if (entered) { + return + } + entered = true + live() + expect(sizes).toEqual([2]) + expect(server.getStatusSnapshot()).toHaveLength(2) + } + if (observer === 'row') { + server.subscribeStatusRowMutations(reenter) + } else { + server.subscribeEnrichedStatus(reenter) + } + await host.seedSessionTabs(ids) + expect(sizes).toEqual([2, 3]) + expect(server.builds).toBe(2) + live('after-session') + expect(sizes).toEqual([2, 3, 4]) + } +) + +it('flushes a committed prefix on lookup error, releases the scope and retries only later eligible rows', async () => { + const { host, ids, store, server, live } = await savedRosterRig(3) + const sizes: number[] = [] + server.subscribeStatusChanges((rows) => sizes.push(rows.length)) + const failureBeforeOutput = new Error('recovery lookup failed') + const getRecord = vi.spyOn(store, 'getRecord').mockImplementationOnce(() => { + throw failureBeforeOutput + }) + await expect(host.seedSessionTabs(ids)).rejects.toBe(failureBeforeOutput) + expect(sizes).toEqual([]) + getRecord.mockRestore() + const get = store.getConversation + const failure = new Error('lookup failed') + const read = vi.spyOn(store, 'getConversation').mockImplementation((id) => { + if (id === ids[1]) { + throw failure + } + return get(id) + }) + await expect(host.seedSessionTabs(ids)).rejects.toBe(failure) + expect(sizes).toEqual([1]) + live() + expect(sizes).toEqual([1, 2]) + read.mockRestore() + await host.seedSessionTabs(ids) + expect(sizes).toEqual([1, 2, 4]) +}) + +it('isolates a caught sink error and listener exceptions, retaining later rows and normal live delivery', async () => { + const { host, ids, sink, server, live } = await savedRosterRig(3) + const group = sink.withSavedStatusGroup + vi.spyOn(sink, 'withSavedStatusGroup').mockImplementation((visit) => + group((publisher) => + visit({ + ...publisher, + publish: (summary, subject) => { + if (summary.sessionId === ids[1]) { + throw new Error('sink failed') + } + publisher.publish(summary, subject) + } + }) + ) + ) + vi.spyOn(console, 'error').mockImplementation(() => {}) + server.subscribeStatusChanges(() => { + throw new Error('observer failed') + }) + const sizes: number[] = [] + server.subscribeStatusChanges((rows) => sizes.push(rows.length)) + await host.seedSessionTabs(ids) + expect(sizes).toEqual([2]) + live() + expect(sizes).toEqual([2, 3]) +}) + +it('groups real removal to an empty roster without manufacturing empty/no-op events', async () => { + const { server, sink, rows } = await savedRosterRig(1) + const record = rows[0]! + const subject = makeStructuredAgentStatusSubject(record.location, record.sessionId) + const sizes: number[] = [] + server.subscribeStatusChanges((roster) => sizes.push(roster.length)) + sink.withSavedStatusGroup(() => {}) + sink.withSavedStatusGroup((publisher) => + publisher.publish( + { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + agent: 'claude', + status: null, + latestPrompt: '', + updatedAt: 100 + }, + subject + ) + ) + expect(sizes).toEqual([]) + server.ingestStructuredStatus( + { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + agent: 'claude', + status: 'idle', + latestPrompt: '', + updatedAt: 100 + }, + subject + ) + sink.withSavedStatusGroup((publisher) => publisher.forget(subject)) + expect(sizes).toEqual([1, 0]) +}) diff --git a/src/main/runtime/agent-session-saved-status-scale.test.ts b/src/main/runtime/agent-session-saved-status-scale.test.ts new file mode 100644 index 00000000000..3b64a2bbf48 --- /dev/null +++ b/src/main/runtime/agent-session-saved-status-scale.test.ts @@ -0,0 +1,133 @@ +import { expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' +import { scaleStore, scaleJournal } from './agent-session-scale.test-fixture' +import { + editPersistedTestAgentSessionStore, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { StructuredAgentSessionSavedStatus } from '../native-chat/agent-session-wire/structured-agent-session-saved-status' +import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { countStatusWriteWork } from './agent-session-status-write-work.test-support' + +it.each([10, 100])( + 'bounds one real published turn edge with %i saved chats and operations', + async (n) => { + const rig = await scaleStore(n) + await editPersistedTestAgentSessionStore(rig.root, (state) => { + for (let i = 0; i < n; i++) { + const row = { + callerKey: 'retained-client', + operationId: `1800000000000-${String(i).padStart(32, '0')}`, + fingerprint: `fp-${i}`, + operationTimestamp: 1_800_000_000_000, + recordedAt: 1_800_000_000_000, + expiresAt: 1_900_000_000_000, + outcome: { status: 'unknown' as const } + } + state.operations[agentSessionOperationKey(row.callerKey, row.operationId)] = row + state.retiredClaimKeys.push({ keyId: `retired-${i}`, retiredAt: 1_800_000_000_000 }) + } + }) + const store = await openTestAgentSessionRecordStore(rig.root) + const id = rig.rows[0]!.sessionId + const journal = await scaleJournal(rig.root, id) + const logger = createStructuredAgentSessionLogger() + const writer = new StructuredAgentSessionSavedStatus(() => ({ store, logger })) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + id, + indexedStatusFeedSession({ + journal, + provider: 'claude', + child: { phase: 'ready', fence: 7, generation: 'child' } + }) + ] + ]), + getConversation: store.getConversation, + logger, + now: () => 1_800_000_000_000, + onPublished: writer.observe + }) + const death = vi.fn() + const unsubscribe = store.onDeathEvidence(death) + const work = countStatusWriteWork() + const db = openTestJournalHostDatabase(rig.root).db + const prepare = db.prepare.bind(db) + const sql = { records: 0, other: 0 } + const observed = new WeakSet() + vi.spyOn(db, 'prepare').mockImplementation((query) => { + const statement = prepare(query) + if (/^(INSERT|DELETE|UPDATE)/.test(query) && !observed.has(statement)) { + observed.add(statement) + const run = statement.run.bind(statement) + vi.spyOn(statement, 'run').mockImplementation((...args) => { + sql[query.startsWith('INSERT INTO agent_session_records ') ? 'records' : 'other']++ + return run(...args) + }) + } + return statement + }) + const turn = { provider: 'claude', sessionId: `provider-${id}-0`, uuid: 'turn' } as const + await journal.appendItem( + { ...turn, uuid: 'user' }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + turn, + { kind: 'turn', turnId: 'turn', state: 'running', startedAt: 123 }, + { fence: 7, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } } + ) + // Journal writes are outside the metadata publication measurement. + sql.records = sql.other = 0 + feed.publish(id) + await writer.drain() + expect(store.getRecord(id)?.lastTurn).toMatchObject({ state: 'open', at: 123 }) + // Generic FIFO record transactions are O(R + O + T + K), with one changed SQLite row. + const records = n + 1 // damaged and malformed rows are excluded from writable records + const tabs = n + 2 + expect(work.counts).toEqual({ + drafts: 1, + recordCopies: records, + operationCopies: n, + recordDiffs: records, + operationDiffs: n, + attribution: records, + deathEvidence: 2 * records, + tabCopies: tabs, + tabComparisons: 2 * tabs, + retiredCopies: n, + retiredComparisons: n + }) + expect(sql).toEqual({ records: 1, other: 0 }) + expect(death).not.toHaveBeenCalled() + work.reset() + sql.records = sql.other = 0 + for (let i = 1; i <= 10; i++) { + await journal.appendItem( + { ...turn, uuid: 'assistant' }, + { + kind: 'message', + role: 'assistant', + state: 'running', + blocks: [{ type: 'text', text: `stream ${i}` }] + }, + { fence: 7, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } } + ) + sql.records = sql.other = 0 + feed.publish(id) + await writer.drain() + expect(work.counts).toEqual( + Object.fromEntries(Object.keys(work.counts).map((key) => [key, 0])) + ) + expect(sql).toEqual({ records: 0, other: 0 }) + } + unsubscribe() + } +) diff --git a/src/main/runtime/agent-session-status-cut-scale.test.ts b/src/main/runtime/agent-session-status-cut-scale.test.ts new file mode 100644 index 00000000000..aaaa08d628e --- /dev/null +++ b/src/main/runtime/agent-session-status-cut-scale.test.ts @@ -0,0 +1,280 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentJournalTurnOutcome } from '../../shared/agent-turn-outcome' +import { orcaShutdownRowClientMessageId } from '../../shared/native-chat/native-chat-orca-stop-cut' +import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps' +import { AgentHookServer } from '../agent-hooks/server' +import { AgentStatusObservedPaneIdentities } from './agent-status-observed-pane-identity' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import type { StatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-deps' +import { AgentSessionSubscribers } from '../native-chat/agent-session-wire/structured-agent-session-subscribers' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { scaleDirectory, scaleJournal, scaleRecord } from './agent-session-scale.test-fixture' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { countStatusCutWork } from './agent-session-status-cut-work.test-support' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +afterEach(() => vi.restoreAllMocks()) + +type Ending = + | 'quit' + | 'update' + | 'crash' + | 'no-cut' + | 'command' + | 'new-request' + | 'pending' + | AgentJournalTurnOutcome +async function publicationRig(history: number, sends: number, ending: Ending) { + const root = await scaleDirectory() + const record = scaleRecord('cut-session') + await seedTestAgentSessionRecordStore(root, { records: [record] }) + const store = await openTestAgentSessionRecordStore(root) + const journal = await scaleJournal(root, record.sessionId) + const options = { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const identity = (uuid: string): AgentJournalItemIdentity => ({ + provider: 'claude', + sessionId: 'provider-cut-session-0', + uuid + }) + for (let i = 0; i < history; i++) { + await journal.appendItem(identity(`history-${i}`), hostTestMessage(`old-${i}`), options) + } + for (let i = 0; i < sends; i++) { + await journal.appendSubmission({ + clientMessageId: `send-${i}`, + payloadFingerprint: `send-${i}`, + body: hostTestMessage(`sent-${i}`), + fence: 7 + }) + await journal.resolveDispatch({ + clientMessageId: `send-${i}`, + state: 'accepted', + providerIdentity: identity(`send-${i}`), + fence: 7 + }) + } + const explicit = + ending === 'success' || + ending === 'failure' || + ending === 'cancellation' || + ending === 'superseded' + ? ending + : undefined + const turn = await journal.appendItem( + identity('turn'), + { + kind: 'turn', + turnId: 'cut-turn', + state: explicit ? 'completed' : 'interrupted', + startedAt: 10, + completedAt: 20, + ...(explicit ? { outcome: explicit } : {}) + }, + options + ) + if (ending !== 'no-cut') { + await journal.appendItem( + { + provider: 'orca', + clientMessageId: orcaShutdownRowClientMessageId(record.sessionId, 7, 'generation') + }, + { + kind: 'status', + text: 'Stopped by Orca', + tone: 'error', + orcaStop: { cause: ending === 'crash' ? 'crash' : ending === 'update' ? 'update' : 'quit' } + }, + { fence: 7, turnScope: { kind: 'turn', turnItemId: turn.itemId } } + ) + } + if (ending === 'new-request') { + await journal.appendItem(identity('new-request'), hostTestMessage('next real request'), options) + } + if (ending === 'pending') { + await journal.appendSubmission({ + clientMessageId: 'pending-send', + payloadFingerprint: 'pending-send', + body: hostTestMessage('pending request'), + fence: 7 + }) + } + if (ending === 'command') { + const command = await journal.appendItem( + identity('command'), + { ...hostTestMessage('/context'), command: { name: 'context' } }, + options + ) + await journal.appendItem( + identity('command-turn'), + { + kind: 'turn', + turnId: 'command-turn', + state: 'completed', + outcome: 'success', + userItemId: command.itemId + }, + options + ) + } + const session: StatusFeedSession = { + journal, + params: { location: record.location, provider: record.provider }, + operationalRevision: 0, + child: { phase: 'ready', generation: 'generation', fence: 7 } + } + const sessions = new Map() + const server = new AgentHookServer() + const sink = agentHookRuntimeDeps( + server, + new AgentStatusObservedPaneIdentities(), + () => ({}) + ).structuredAgentStatusSink + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getConversation: store.getConversation, + now: () => 1_800_000_000_000, + logger: createStructuredAgentSessionLogger(), + statusSink: () => sink + }) + const events = vi.fn() + feed.subscribe({ id: 'status', emit: events }) + sessions.set(record.sessionId, session) + const subscribers = new AgentSessionSubscribers({ + onJournalPublished: (id, source) => feed.publish(id, source) + }) + const counts = countStatusCutWork() + const publish = () => subscribers.publish(record.sessionId, journal) + return { + record, + journal, + store, + session, + server, + feed, + events, + counts, + publish, + identity, + options + } +} + +it.each([ + [10, 10], + [100, 10], + [10, 100] +])( + 'counts the actual scoped quit reader with H=%i and S=%i, including cache reuse and invalidation', + async (history, sends) => { + const rig = await publicationRig(history, sends, 'quit') + const { counts, publish, events, server, feed, record, journal } = rig + publish() + expect(feed.readPublished(record.sessionId)).toMatchObject({ + status: 'idle', + turnOutcome: 'cancellation', + statusStartedAt: 20 + }) + expect(server.getStatusSnapshot()[0]?.mainAgent).toMatchObject({ + state: 'done', + outcome: 'cancellation' + }) + expect(counts).toMatchObject({ + calls: 1, + submissions: sends, + pending: sends, + indexes: 1, + commandEntries: 0 + }) + expect(counts.items).toBe(history + sends + 2) + expect(counts.backward).toBe(2) + expect(counts.scoped).toBe(counts.items) + expect(counts.bodyEntries).toBe(counts.items) + expect(counts.commandVisits).toBe(counts.items) + expect(counts.scoped).toBeGreaterThan(0) + expect(counts.backward).toBeGreaterThan(0) + const readerVisits = + counts.pending + counts.bodyEntries + counts.commandVisits + counts.backward + counts.scoped + expect(readerVisits).toBeLessThanOrEqual(4 * counts.items + sends) + const warm = { ...counts } + const emitted = events.mock.calls.length + publish() + publish() + expect(counts).toEqual(warm) + expect(events).toHaveBeenCalledTimes(emitted) + await rig.store.setConversationName(record.sessionId, 'Renamed after quit') + publish() + expect(counts).toEqual(warm) + await journal.appendItem( + rig.identity('late-detail'), + { kind: 'status', text: 'late detail', tone: 'info' }, + rig.options + ) + const before = events.mock.calls.length + publish() + expect(counts.calls).toBe(2) + const afterJournal = { ...counts } + publish() + expect(counts).toEqual(afterJournal) + // The real row-free owner observation changes cache identity even if outward summary equality suppresses it. + const beforeWork = events.mock.calls.length + rig.session.lastEndedChild = { fence: 7, rootGone: true } + rig.session.operationalRevision = 1 + const sequence = journal.cursor().sequence + publish() + expect(journal.cursor().sequence).toBe(sequence) + expect(counts.calls).toBe(3) + const afterWork = { ...counts } + publish() + expect(counts).toEqual(afterWork) + expect(events).toHaveBeenCalledTimes(beforeWork) + expect(beforeWork).toBeGreaterThanOrEqual(before) + expect( + counts.bodyEntries + counts.commandVisits + counts.backward + counts.scoped + counts.pending + ).toBeLessThanOrEqual(4 * counts.items + counts.submissions) + } +) + +it.each([ + ['update', 'idle', 'cancellation', true], + ['crash', 'idle', 'interruption', true], + ['no-cut', 'idle', 'interruption', true], + ['command', 'idle', 'cancellation', true], + ['new-request', 'idle', 'interruption', true], + ['pending', 'working', undefined, false], + ['success', 'idle', 'success', false], + ['failure', 'idle', 'failure', false], + ['cancellation', 'idle', 'cancellation', false] +] satisfies [Ending, string, string | undefined, boolean][])( + 'keeps %s outcome scoping through the real publication and canonical sink', + async (ending, status, outcome, scans) => { + const { publish, feed, server, record, counts, events } = await publicationRig(10, 10, ending) + publish() + expect(feed.readPublished(record.sessionId)).toMatchObject({ status }) + expect(feed.readPublished(record.sessionId)?.turnOutcome).toBe(outcome) + expect(events).toHaveBeenLastCalledWith({ + type: 'status', + session: feed.readPublished(record.sessionId) + }) + const mainAgent = server.getStatusSnapshot()[0]?.mainAgent + expect(mainAgent?.state === 'done' ? mainAgent.outcome : undefined).toBe(outcome) + expect(server.getStatusSnapshot()[0]?.mainAgent).toMatchObject({ + state: status === 'working' ? 'working' : 'done', + ...(outcome ? { outcome } : {}) + }) + expect(counts.indexes).toBe(scans ? 1 : 0) + if (ending === 'command') { + expect(counts.commandEntries).toBe(1) + } + if (!scans) { + expect( + counts.pending + counts.bodyEntries + counts.commandVisits + counts.backward + counts.scoped + ).toBe(0) + } + } +) diff --git a/src/main/runtime/agent-session-status-cut-work.test-support.ts b/src/main/runtime/agent-session-status-cut-work.test-support.ts new file mode 100644 index 00000000000..e729e6ecd09 --- /dev/null +++ b/src/main/runtime/agent-session-status-cut-work.test-support.ts @@ -0,0 +1,103 @@ +import { vi } from 'vitest' +import * as cut from '../../shared/native-chat/native-chat-orca-stop-cut' +import * as commands from '../../shared/structured-agent-session-command-entry' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../shared/agent-session-journal-types' + +/** Count only the original status reader, after production context selection. */ +export function countStatusCutWork() { + const counts = { + calls: 0, + items: 0, + submissions: 0, + pending: 0, + bodyEntries: 0, + commandVisits: 0, + backward: 0, + scoped: 0, + indexes: 0, + commandEntries: 0 + } + let reading = false + const index = commands.structuredAgentSessionCommandTurnItemIds + vi.spyOn(commands, 'structuredAgentSessionCommandTurnItemIds').mockImplementation((items) => { + const result = index(items) + if (reading) { + counts.indexes++ + counts.commandEntries += result.size + } + return result + }) + function watchItems(items: readonly AgentJournalRenderItem[]) { + let iterators = 0 + return new Proxy(items, { + get(target, property) { + if (property === 'map') { + return function map( + callback: ( + item: AgentJournalRenderItem, + i: number, + rows: readonly AgentJournalRenderItem[] + ) => U, + thisArg?: unknown + ) { + return target.map((item, i, rows) => { + counts.bodyEntries++ + return callback.call(thisArg, item, i, rows) + }) + } + } + if (property === Symbol.iterator) { + return function* (): Generator { + const phase = iterators++ === 0 ? 'commandVisits' : 'scoped' + for (const item of target) { + counts[phase]++ + yield item + } + return undefined + } + } + if (property === 'length') { + return target.length + } + if (typeof property === 'string' && /^(0|[1-9]\d*)$/.test(property)) { + const index = Number(property) + if (Number.isSafeInteger(index)) { + counts.backward++ + return target[index] + } + } + throw new Error(`Uncounted status-cut item property: ${String(property)}`) + } + }) + } + function watchSubmissions(submissions: readonly Pick[]) { + return new Proxy(submissions, { + get(target, property) { + if (property === 'some') { + return (predicate: Parameters[0], thisArg?: unknown) => + target.some((item, i, rows) => { + counts.pending++ + return predicate.call(thisArg, item, i, rows) + }) + } + throw new Error(`Uncounted status-cut submission property: ${String(property)}`) + } + }) + } + const outcome = cut.nativeChatStatusTurnOutcome + vi.spyOn(cut, 'nativeChatStatusTurnOutcome').mockImplementation((request, items, submissions) => { + counts.calls++ + counts.items += items.length + counts.submissions += submissions.length + reading = true + try { + return outcome(request, watchItems(items), watchSubmissions(submissions)) + } finally { + reading = false + } + }) + return counts +} diff --git a/src/main/runtime/agent-session-status-write-work.test-support.ts b/src/main/runtime/agent-session-status-write-work.test-support.ts new file mode 100644 index 00000000000..110d70b9cef --- /dev/null +++ b/src/main/runtime/agent-session-status-write-work.test-support.ts @@ -0,0 +1,139 @@ +import { vi } from 'vitest' +import * as drafts from './agent-session-store-draft' +import * as attribution from './agent-session-runtime-attribution' +import { AgentSessionTabTable } from './agent-session/agent-session-tab-table' +import type { AgentSessionStoreState } from './agent-session-store-state' + +/** Transparent counters on actual iterator yields, including the two death-listener passes. */ +export function countStatusWriteWork() { + const counts = { + drafts: 0, + recordCopies: 0, + operationCopies: 0, + recordDiffs: 0, + operationDiffs: 0, + attribution: 0, + deathEvidence: 0, + tabCopies: 0, + tabComparisons: 0, + retiredCopies: 0, + retiredComparisons: 0 + } + let phase: 'copy' | 'diff' | 'attribute' | 'death' = 'death' + const observed = new WeakSet() + function watch(map: Map, records: boolean) { + if (observed.has(map)) { + return + } + observed.add(map) + const iterate = map[Symbol.iterator].bind(map) + vi.spyOn(map, Symbol.iterator).mockImplementation(function* () { + for (const entry of iterate()) { + if (phase === 'copy') { + counts[records ? 'recordCopies' : 'operationCopies']++ + } else if (phase === 'diff') { + counts[records ? 'recordDiffs' : 'operationDiffs']++ + } else if (phase === 'attribute') { + counts.attribution++ + } else { + counts.deathEvidence++ + } + yield entry + } + return undefined + }) + } + function watchRetired(state: AgentSessionStoreState) { + if (observed.has(state.retiredClaimKeys)) { + return + } + observed.add(state.retiredClaimKeys) + const iterate = state.retiredClaimKeys[Symbol.iterator].bind(state.retiredClaimKeys) + vi.spyOn(state.retiredClaimKeys, Symbol.iterator).mockImplementation(function* () { + for (const entry of iterate()) { + counts.retiredCopies++ + yield entry + } + return undefined + }) + const some = state.retiredClaimKeys.some.bind(state.retiredClaimKeys) + vi.spyOn(state.retiredClaimKeys, 'some').mockImplementation((predicate, thisArg) => + some((entry, i, rows) => { + counts.retiredComparisons++ + return predicate.call(thisArg, entry, i, rows) + }) + ) + } + const cloneTabs = AgentSessionTabTable.prototype.clone + vi.spyOn(AgentSessionTabTable.prototype, 'clone').mockImplementation(function ( + this: AgentSessionTabTable + ) { + // Each constructor put copies one entry into both inverse indexes. + const result = cloneTabs.call(this) + counts.tabCopies += result.entries().length + return result + }) + const equalTabs = AgentSessionTabTable.prototype.equals + vi.spyOn(AgentSessionTabTable.prototype, 'equals').mockImplementation(function ( + this: AgentSessionTabTable, + other + ) { + const entries = this.entries + const spy = vi.spyOn(this, 'entries').mockImplementation(() => { + const rows = entries.call(this) + counts.tabComparisons += rows.length + return rows + }) + const otherEntries = other.entries + const otherSpy = vi.spyOn(other, 'entries').mockImplementation(() => { + const rows = otherEntries.call(other) + counts.tabComparisons += rows.length + return rows + }) + try { + return equalTabs.call(this, other) + } finally { + spy.mockRestore() + otherSpy.mockRestore() + } + }) + const draft = drafts.draftAgentSessionStoreState + vi.spyOn(drafts, 'draftAgentSessionStoreState').mockImplementation((published) => { + counts.drafts++ + watch(published.records, true) + watch(published.operations, false) + watchRetired(published) + phase = 'copy' + const next = draft(published) + phase = 'death' + watch(next.records, true) + watch(next.operations, false) + return next + }) + const attribute = attribution.attributeAgentSessionRuntime + vi.spyOn(attribution, 'attributeAgentSessionRuntime').mockImplementation((...args) => { + phase = 'attribute' + try { + return attribute(...args) + } finally { + phase = 'death' + } + }) + const writes = drafts.agentSessionStoreDraftRowWrites + vi.spyOn(drafts, 'agentSessionStoreDraftRowWrites').mockImplementation((...args) => { + phase = 'diff' + try { + return writes(...args) + } finally { + phase = 'death' + } + }) + return { + counts, + reset: () => { + for (const key of Object.keys(counts)) { + Reflect.set(counts, key, 0) + } + } + } +} diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index d9a3a0d54f8..1ab20129739 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -94,7 +94,8 @@ export class AgentSessionStoreTransactions { constructor( private readonly journalDatabase: JournalHostDatabase, loaded: AgentSessionStoreState, - hostId: string + hostId: string, + readonly legacySessionTabIds: readonly string[] ) { freezeRows(loaded, null) this.published = { diff --git a/src/main/runtime/agent-session-tab-layout-queue.ts b/src/main/runtime/agent-session-tab-layout-queue.ts index e1f4da66739..6a8191c3cbf 100644 --- a/src/main/runtime/agent-session-tab-layout-queue.ts +++ b/src/main/runtime/agent-session-tab-layout-queue.ts @@ -8,7 +8,11 @@ import { type ChatTabLayoutCommand } from './agent-session/agent-session-tab-layout' -export type QueuedChatTabChange = { worktreeId: string; change: ChatTabChange } +export type QueuedChatTabChange = { + worktreeId: string + change: ChatTabChange + eligible?: () => boolean +} /** Serialize changes against the current layout so close follows open; log refusals and continue. */ export function createChatTabLayoutQueue(deps: { @@ -43,6 +47,9 @@ export function createChatTabLayoutQueue(deps: { } for (const queued of changes) { try { + if (queued.eligible && !queued.eligible()) { + continue + } const command = commandNow(queued) const reply = command ? await deps.apply(command) : null if (command && reply && !reply.ok) { diff --git a/src/main/runtime/agent-session/agent-session-tab-table.ts b/src/main/runtime/agent-session/agent-session-tab-table.ts index 6ee848c52f8..d20901732dd 100644 --- a/src/main/runtime/agent-session/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session/agent-session-tab-table.ts @@ -1,3 +1,4 @@ +import { AgentSessionRecordPublication } from '../agent-session-record-publication' import { isAgentSessionSurfaceTabId } from '../../../shared/agent-session-surface-tab-id' import type { AgentSessionReserveRequest } from './ownership/agent-session-reservation-admission' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' @@ -80,6 +81,38 @@ export class AgentSessionTabTable { } } +/** Derive the complete legacy selection only at a real ownership/visibility boundary. */ +export function effectiveAgentSessionTabs( + state: AgentSessionStoreState, + legacyIds: readonly string[], + ownShow?: string +): AgentSessionTabTable { + if (state.sessionTabs) { + return state.sessionTabs + } + const tabs = new AgentSessionTabTable() + for (const id of legacyIds) { + if (id !== ownShow && hasAgentSessionConversation(state, id)) { + tabs.show(id) + } + } + return tabs +} + +/** An empty passive reconstruction cannot establish absence as authority. */ +export function initializeAgentSessionTabs( + state: AgentSessionStoreState, + legacyIds: readonly string[] +): void { + if (state.sessionTabs) { + return + } + const tabs = effectiveAgentSessionTabs(state, legacyIds) + if (tabs.entries().length > 0) { + state.sessionTabs = tabs + } +} + export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] { return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => hasAgentSessionConversation(state, sessionId) @@ -97,12 +130,17 @@ export function setAgentSessionTabVisibility( state: AgentSessionStoreState, sessionId: string, visible: boolean, - tabId?: string + tabId?: string, + legacyIds: readonly string[] = [] ): void { if (visible && !hasAgentSessionConversation(state, sessionId)) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } - state.sessionTabs ??= new AgentSessionTabTable() + state.sessionTabs ??= effectiveAgentSessionTabs( + state, + legacyIds, + visible && tabId !== undefined ? sessionId : undefined + ) if (visible) { state.sessionTabs.show(sessionId, tabId) } else { @@ -112,11 +150,12 @@ export function setAgentSessionTabVisibility( export function showAgentSessionTabs( state: AgentSessionStoreState, - sessionIds: readonly string[] + sessionIds: readonly string[], + legacyIds: readonly string[] = [] ): void { for (const sessionId of sessionIds) { if (hasAgentSessionConversation(state, sessionId)) { - setAgentSessionTabVisibility(state, sessionId, true) + setAgentSessionTabVisibility(state, sessionId, true, undefined, legacyIds) } } } @@ -126,7 +165,8 @@ export type PersistedAgentSessionTab = { tabId: string; sessionId: string } /** Check tab-id conflicts before spawn; claim only on publication so failed creates leave no restorable tab. */ export function assertReservedTabUnheld( state: AgentSessionStoreState, - request: AgentSessionReserveRequest + request: AgentSessionReserveRequest, + legacyIds: readonly string[] = [] ): void { if (request.surfaceTabId === undefined) { return @@ -136,8 +176,94 @@ export function assertReservedTabUnheld( reason: 'requestMalformed' }) } - const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) + const holder = effectiveAgentSessionTabs(state, legacyIds, request.sessionId).sessionIdFor( + request.surfaceTabId + ) if (holder !== undefined && holder !== request.sessionId) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } } + +/** Publish tab visibility through the same committed store boundary as record changes. */ +export abstract class AgentSessionRecordTabs extends AgentSessionRecordPublication { + private readonly legacyTabIds: ReadonlySet = new Set( + this.transactions.legacySessionTabIds + ) + + listVisibleSessionIds = (): string[] => + this.transactions.state.sessionTabs + ? listVisibleAgentSessionIds(this.transactions.state) + : this.transactions.legacySessionTabIds.filter((id) => this.isSessionTabVisible(id)) + + isSessionTabVisible = (id: string): boolean => + (this.transactions.state.sessionTabs + ? this.transactions.state.sessionTabs.tabIdFor(id) !== undefined + : this.legacyTabIds.has(id)) && hasAgentSessionConversation(this.transactions.state, id) + + isSessionTabRetired = (id: string): boolean => + this.transactions.state.sessionTabs !== null && !this.isSessionTabVisible(id) + + initializeSessionTabs(): Promise { + return this.transact((draft) => + initializeAgentSessionTabs(draft, this.transactions.legacySessionTabIds) + ) + } + + getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => + agentSessionVisibleTabIndex(this.transactions.state) + + getSessionTabId = (sessionId: string): string | null => + this.transactions.state.sessionTabs?.tabIdFor(sessionId) ?? null + + /** Persist chat-tab identity separately from rollback-sensitive profile tabs; explicit tabId restores hidden tabs under their old ids. */ + setSessionTabVisibility( + sessionId: string, + visible: boolean, + tabId?: string, + check?: () => void + ): Promise { + return this.transact((draft) => { + check?.() + return setAgentSessionTabVisibility( + draft, + sessionId, + visible, + tabId, + this.transactions.legacySessionTabIds + ) + }) + } + + setSwitchedSessionTabVisibility( + sessionId: string, + tabId: string, + visible: boolean, + admit: () => boolean + ): Promise { + return this.transact((draft) => { + if ( + (visible + ? draft.sessionTabs?.tabIdFor(sessionId) !== undefined + : draft.sessionTabs?.tabIdFor(sessionId) !== tabId) || + !admit() + ) { + return false + } + setAgentSessionTabVisibility( + draft, + sessionId, + visible, + tabId, + this.transactions.legacySessionTabIds + ) + return true + }) + } + + /** Publish the complete tab index in one write; a partial index would drop remaining chats on restart. */ + showSessionTabs(sessionIds: readonly string[]): Promise { + return this.transact((draft) => + showAgentSessionTabs(draft, sessionIds, this.transactions.legacySessionTabIds) + ) + } +} diff --git a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts index fc4f322f085..50e0304a05e 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts @@ -157,7 +157,8 @@ export function validateAgentSessionReservationInput(request: AgentSessionReserv export function applyAgentSessionReservation( state: AgentSessionStoreState, request: AgentSessionReserveRequest, - leaseTtlMs: number + leaseTtlMs: number, + legacyIds: readonly string[] = [] ): { record: AgentSessionRecord disposition: Exclude @@ -181,7 +182,7 @@ export function applyAgentSessionReservation( if (request.expectedFence !== null) { throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } - assertReservedTabUnheld(state, request) + assertReservedTabUnheld(state, request, legacyIds) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } } const existing = selected.record @@ -201,7 +202,7 @@ export function applyAgentSessionReservation( if (request.expectedFence === null && !recreatable) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } - assertReservedTabUnheld(state, request) + assertReservedTabUnheld(state, request, legacyIds) const pinned = { ...existing, ...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), @@ -281,7 +282,8 @@ function createAgentSessionRecord( export function commitAgentSessionReservation( state: AgentSessionStoreState, request: AgentSessionReserveRequest, - leaseTtlMs: number + leaseTtlMs: number, + legacyIds: readonly string[] = [] ): AgentSessionReserveResult { const decision = evaluateAgentSessionReserveOperation(state, request) const existing = state.records.get(request.sessionId) @@ -296,7 +298,7 @@ export function commitAgentSessionReservation( throw agentSessionRefusalError(decision.code, decision.details) } const row = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) - return reserveWithOperationRow(state, continued, row, leaseTtlMs) + return reserveWithOperationRow(state, continued, row, leaseTtlMs, legacyIds) } if (decision.decision === 'replay') { const record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId) @@ -304,21 +306,22 @@ export function commitAgentSessionReservation( return { record, disposition: 'replayed', operationRow: decision.row } } if (continued) { - return reserveWithOperationRow(state, continued, decision.row, leaseTtlMs) + return reserveWithOperationRow(state, continued, decision.row, leaseTtlMs, legacyIds) } const retried = admitPendingAgentSessionReservationReplay(record, request) return { record: retried, disposition: 'replayed', operationRow: decision.row } } - return reserveWithOperationRow(state, request, decision.row, leaseTtlMs) + return reserveWithOperationRow(state, request, decision.row, leaseTtlMs, legacyIds) } function reserveWithOperationRow( state: AgentSessionStoreState, request: AgentSessionReserveRequest, row: AgentSessionOperationRow, - leaseTtlMs: number + leaseTtlMs: number, + legacyIds: readonly string[] = [] ): AgentSessionReserveResult { - const result = applyAgentSessionReservation(state, request, leaseTtlMs) + const result = applyAgentSessionReservation(state, request, leaseTtlMs, legacyIds) state.operations.set(agentSessionOperationKey(row.callerKey, row.operationId), row) state.records.set(result.record.sessionId, result.record) return { ...result, operationRow: row } diff --git a/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.test.ts b/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.test.ts index 767d0716c44..199ff9e6b9b 100644 --- a/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.test.ts +++ b/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.test.ts @@ -1,14 +1,19 @@ -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' // An exit that settles while teardown drains recovery wakes delivery; a fresh child started then // would only be killed by the teardown after it. it('stops delivery before it drains the recovery of exits already observed', async () => { const order: string[] = [] + const drained = Promise.withResolvers() const installed: InstalledRuntime = { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. host: { stopDelivery: () => order.push('stop-delivery'), + drainStatusWrites: async () => { + order.push('drain-status') + await drained.promise + }, flushAllStreamedEvents: async () => { order.push('flush') } @@ -21,7 +26,18 @@ it('stops delivery before it drains the recovery of exits already observed', asy } } - await tearDownRuntime(installed, 'quit') + const stopping = tearDownRuntime(installed, 'quit') + await vi.waitFor(() => expect(order).toContain('drain-status')) + expect(order).not.toContain('close-database') + drained.resolve() + await stopping - expect(order.slice(0, 2)).toEqual(['stop-delivery', 'wait-for-recovery']) + expect(order).toEqual([ + 'stop-delivery', + 'wait-for-recovery', + 'flush', + 'wait-for-recovery', + 'drain-status', + 'close-database' + ]) }) diff --git a/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.ts b/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.ts index 192b7afb6e3..640afa32fab 100644 --- a/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/agent-session/structured-agent-session-runtime-teardown.ts @@ -57,6 +57,7 @@ export async function tearDownRuntime( } catch (error) { failures.push(error) } + await installed.host.drainStatusWrites() if (failures.length === 0) { // Close only after all sinks drain and conversations settle. installed.journalDatabase.close() diff --git a/src/main/runtime/agent-session/structured-agent-session-tab-index-seed.ts b/src/main/runtime/agent-session/structured-agent-session-tab-index-seed.ts index d0e455744c5..18d0be6750e 100644 --- a/src/main/runtime/agent-session/structured-agent-session-tab-index-seed.ts +++ b/src/main/runtime/agent-session/structured-agent-session-tab-index-seed.ts @@ -1,42 +1,7 @@ -// Seed legacy restored chats in one transaction: a partial index would read complete on next launch and lose the remaining tabs. - -import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' import type { Tab } from '../../../shared/tab-types' import type { ChatTabChange } from './agent-session-tab-layout' import { savedSessionId } from '../saved-structured-agent-session-restoration' -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 & Pick) | null | undefined, - targets: readonly string[], - restored: readonly string[] -): Promise { - if (!host?.getPersistedVisibleSessionTabIndex || !host.showSessionTabs) { - return - } - const index = host.getPersistedVisibleSessionTabIndex() - const listed = new Set(index.present ? index.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) => - host.deps.logger.warn('recording restored chat tabs failed', { - scope: 'tab-index-seed', - sessionIds: unlisted, - error - }) - ) - } -} - /** Match windowless saved tabs to the index: add legacy missing tabs and remove closed leftovers only while both records exist. */ export function chatTabChangesMatchingIndex( savedTabsByWorktree: Record, diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index dd19a0aa2e9..3be938d2db5 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -1,3 +1,5 @@ +import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent-session-restoration' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { RuntimeConstructionBindings, RuntimeConstructionDependencies @@ -116,6 +118,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo ) { super() this.store = store + this.legacyStructuredSessionTabIds = Object.freeze( + collectSavedStructuredAgentSessionIds( + store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ) + ) this.machineName.start() this.getSetupTerminalTitle = deps?.getSetupTerminalTitle ?? (() => 'Setup') this.prepareClaudeAuth = deps?.prepareClaudeAuth diff --git a/src/main/runtime/construction/runtime-identity-state.ts b/src/main/runtime/construction/runtime-identity-state.ts index f59357dffb2..1c58a726054 100644 --- a/src/main/runtime/construction/runtime-identity-state.ts +++ b/src/main/runtime/construction/runtime-identity-state.ts @@ -110,6 +110,8 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic protected tabs = new Map() + protected legacyStructuredSessionTabIds: readonly string[] = [] + mobileSessionTabsByWorktree = new Map() storeMobileSessionSnapshot( 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 fa3399705ce..a406f7de30a 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -1,12 +1,99 @@ +import { partialRuntimeStore } from './runtime-store-partial-fixture' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { afterEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from './orca-runtime' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' afterEach(() => setStructuredAgentSessionHost(null)) +function installRestoreHost( + overrides: Partial< + Pick< + StructuredAgentSessionHost, + | 'reconcileRestartLeases' + | 'seedSessionTabs' + | 'listSessionTabs' + | 'getPersistedVisibleSessionTabIndex' + | 'close' + | 'setSessionTabVisibility' + > + > +) { + const { host } = hostTestState() + const index = overrides.getPersistedVisibleSessionTabIndex?.() + const rows = overrides.listSessionTabs?.() ?? [] + const records = new Map( + rows.map((row) => { + const sessionId = row.sessionId.replace(/^(agent-session:)+/, '') + return [ + sessionId, + agentSessionConversationView( + agentSessionRecordFixture({ + sessionId, + provider: row.agent, + location: { workspaceId: row.workspaceId } + }) + ) + ] as const + }) + ) + for (const sessionId of index?.sessionIds ?? []) { + if (!records.has(sessionId)) { + records.set(sessionId, agentSessionConversationView(agentSessionRecordFixture({ sessionId }))) + } + } + const visible = new Set(index?.present ? index.sessionIds : records.keys()) + let present = index?.present ?? visible.size > 0 + vi.spyOn(host.deps.store, 'getConversation').mockImplementation((id) => records.get(id) ?? null) + vi.spyOn(host.deps.store, 'isSessionTabVisible').mockImplementation( + (id) => visible.has(id) && records.has(id) + ) + vi.spyOn(host.deps.store, 'isSessionTabRetired').mockImplementation( + (id) => present && !visible.has(id) + ) + vi.spyOn(host.deps.store, 'getVisibleSessionTabIndex').mockImplementation(() => ({ + present, + sessionIds: [...visible] + })) + vi.spyOn(host, 'showSessionTabs').mockResolvedValue(undefined) + const { setSessionTabVisibility, ...methods } = overrides + Object.assign(host, methods) + const listed = + rows.length > 0 + ? rows + : [...records.values()].map((record) => ({ + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + agent: record.provider + })) + vi.spyOn(host, 'listSessionTabs').mockImplementation(() => + listed.filter((row) => visible.has(row.sessionId.replace(/^(agent-session:)+/, ''))) + ) + vi.spyOn(host, 'getPersistedVisibleSessionTabIndex').mockImplementation(() => + host.deps.store.getVisibleSessionTabIndex() + ) + vi.spyOn(host, 'setSessionTabVisibility').mockImplementation(async (...args) => { + await setSessionTabVisibility?.(...args) + const [id, show] = args + present = true + if (show) { + visible.add(id) + } else { + visible.delete(id) + } + }) + setStructuredAgentSessionHost(host) + return host +} + describe('structured session cold restoration', () => { it('publishes a versioned title change and derives listAll titles from the record', async () => { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) runtime.projectStructuredAgentSessionTab({ activate: false, sessionId: 'session-1', @@ -44,7 +131,7 @@ describe('structured session cold restoration', () => { unsubscribe() }) it('skips every heavy recovery step when no durable session store exists', async () => { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) const refresh = vi.fn(async () => new Set()) const ensureHost = vi.fn(async () => undefined) const reconcileRestartLeases = vi.fn(async () => undefined) @@ -66,11 +153,11 @@ describe('structured session cold restoration', () => { }) it('keeps historical journal parsing outside the terminal-safety fence', async () => { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) const refresh = vi.fn(async () => new Set()) const ensureHost = vi.fn(async () => undefined) const reconcileRestartLeases = vi.fn(async () => undefined) - const restoreReadableSessions = vi.fn(async () => undefined) + const seedSessionTabs = vi.fn(async () => undefined) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean refreshMobileSessionPtyRecords(): Promise | null> @@ -79,23 +166,23 @@ describe('structured session cold restoration', () => { internal.hasPersistedStructuredAgentSessionStore = () => true internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost - setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) + installRestoreHost({ reconcileRestartLeases, seedSessionTabs }) await runtime.prepareStructuredAgentSessionStartupRestoration() expect(ensureHost).toHaveBeenCalledOnce() expect(refresh).toHaveBeenCalledOnce() expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).not.toHaveBeenCalled() + expect(seedSessionTabs).not.toHaveBeenCalled() }) it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) const hydrate = vi.fn() const refresh = vi.fn(async () => new Set()) const ensureHost = vi.fn(async () => undefined) const reconcileRestartLeases = vi.fn(async () => undefined) - const restoreReadableSessions = vi.fn(async () => undefined) + const seedSessionTabs = vi.fn(async () => undefined) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean getKnownWorkspaceSessionWorktreeIds(): Set @@ -111,11 +198,11 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrate internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost - setStructuredAgentSessionHost({ + installRestoreHost({ reconcileRestartLeases, - restoreReadableSessions, + seedSessionTabs, listSessionTabs: () => [] - } as never) + }) const first = runtime.restoreStructuredAgentSessionTabs() const second = runtime.restoreStructuredAgentSessionTabs() @@ -129,7 +216,7 @@ describe('structured session cold restoration', () => { expect(hydrate).toHaveBeenCalledWith() expect(refresh).toHaveBeenCalledOnce() expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).toHaveBeenCalledOnce() + expect(seedSessionTabs).toHaveBeenCalledOnce() expect(ensureHost).toHaveBeenCalledOnce() expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan( refresh.mock.invocationCallOrder[0] ?? Infinity @@ -138,16 +225,16 @@ describe('structured session cold restoration', () => { reconcileRestartLeases.mock.invocationCallOrder[0] ?? Infinity ) expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan( - restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity + seedSessionTabs.mock.invocationCallOrder[0] ?? Infinity ) - expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan( + expect(seedSessionTabs.mock.invocationCallOrder[0]).toBeLessThan( hydrate.mock.invocationCallOrder[0] ?? Infinity ) }) it('prefers the durable visible-session index after a legacy profile drops agent tabs', async () => { - const runtime = new OrcaRuntimeService() - const restoreReadableSessions = vi.fn(async () => undefined) + const runtime = new OrcaRuntimeService(partialRuntimeStore()) + const seedSessionTabs = vi.fn(async () => undefined) const internal = runtime as unknown as { store: { getWorkspaceSession: () => unknown } hasPersistedStructuredAgentSessionStore(): boolean @@ -156,39 +243,49 @@ describe('structured session cold restoration', () => { refreshMobileSessionPtyRecords(): Promise | null> ensureStructuredAgentSessionHost(): Promise } - internal.store = { - getWorkspaceSession: () => ({ - activeRepoId: null, - activeWorktreeId: 'workspace-1', - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - unifiedTabs: { 'workspace-1': [] } - }) - } + const getWorkspaceSession = (): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { 'workspace-1': [] } + }) + internal.store = { ...partialRuntimeStore({ getWorkspaceSession }), getWorkspaceSession } internal.hasPersistedStructuredAgentSessionStore = () => true internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined - setStructuredAgentSessionHost({ + installRestoreHost({ reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['session-survives-rollback'] }), - restoreReadableSessions, + seedSessionTabs, listSessionTabs: () => [] - } as never) + }) await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) + expect(seedSessionTabs).toHaveBeenCalledExactlyOnceWith() + expect(hostTestState().host.getPersistedVisibleSessionTabIndex()).toEqual({ + present: true, + sessionIds: ['session-survives-rollback'] + }) + expect(hostTestState().host.isSessionTabVisible('session-survives-rollback')).toBe(true) + expect((await runtime.listMobileSessionTabs('id:workspace-1')).tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'agent-session', sessionId: 'session-survives-rollback' }) + ]) + ) }) it('treats an empty durable visible-session index as authoritative', async () => { - const runtime = new OrcaRuntimeService() - const restoreReadableSessions = vi.fn(async () => undefined) + const runtime = new OrcaRuntimeService(partialRuntimeStore()) + const seedSessionTabs = vi.fn(async () => undefined) const internal = runtime as unknown as { store: { getWorkspaceSession: () => unknown } hasPersistedStructuredAgentSessionStore(): boolean @@ -197,51 +294,57 @@ describe('structured session cold restoration', () => { refreshMobileSessionPtyRecords(): Promise | null> ensureStructuredAgentSessionHost(): Promise } - internal.store = { - getWorkspaceSession: () => ({ - activeRepoId: null, - activeWorktreeId: 'workspace-1', - activeTabId: 'agent-session:closed-session', - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - activeTabIdByWorktree: { 'workspace-1': 'agent-session:closed-session' }, - unifiedTabs: { - 'workspace-1': [ - { - id: 'agent-session:closed-session', - entityId: 'closed-session', - groupId: 'group-1', - worktreeId: 'workspace-1', - contentType: 'agent-session', - label: 'Codex Chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - }) - } + const getWorkspaceSession = (): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: 'agent-session:closed-session', + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: { 'workspace-1': 'agent-session:closed-session' }, + unifiedTabs: { + 'workspace-1': [ + { + id: 'agent-session:closed-session', + entityId: 'closed-session', + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + internal.store = { ...partialRuntimeStore({ getWorkspaceSession }), getWorkspaceSession } internal.hasPersistedStructuredAgentSessionStore = () => true internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined - setStructuredAgentSessionHost({ + installRestoreHost({ reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), - restoreReadableSessions, + seedSessionTabs, listSessionTabs: () => [] - } as never) + }) await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith([]) + expect(seedSessionTabs).toHaveBeenCalledExactlyOnceWith() + expect(hostTestState().host.getPersistedVisibleSessionTabIndex()).toEqual({ + present: true, + sessionIds: [] + }) + expect(hostTestState().host.isSessionTabVisible('closed-session')).toBe(false) + expect((await runtime.listMobileSessionTabs('id:workspace-1')).tabs).toEqual([]) }) it('normalizes a restored tab id and removes it when closed', async () => { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) const closeSessionTab = vi.fn(async () => undefined) const closeStructuredSession = vi.fn(async () => { const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') @@ -261,9 +364,9 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined - setStructuredAgentSessionHost({ + installRestoreHost({ reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + seedSessionTabs: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, listSessionTabs: () => [ @@ -273,7 +376,7 @@ describe('structured session cold restoration', () => { agent: 'codex' } ] - } as never) + }) runtime.syncWindowGraph(1, { tabs: [], leaves: [], @@ -361,7 +464,7 @@ describe('structured session cold restoration', () => { }) it('publishes restored Claude tabs with the Claude title', async () => { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) const project = vi.spyOn(runtime, 'projectStructuredAgentSessionTab') const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean @@ -375,9 +478,9 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined - setStructuredAgentSessionHost({ + installRestoreHost({ reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + seedSessionTabs: async () => undefined, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', @@ -385,7 +488,7 @@ describe('structured session cold restoration', () => { agent: 'claude' } ] - } as never) + }) await runtime.restoreStructuredAgentSessionTabs() @@ -411,7 +514,7 @@ describe('structured session cold restoration', () => { }) it('commits the host close when the renderer already removed the structured tab', async () => { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) runtime.setNotifier({ closeSessionTab: vi.fn(async () => { throw new Error('session_tab_not_found') diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts index 3c26b1eeb6a..faff477eaf3 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts @@ -633,6 +633,8 @@ describe('OrcaRuntimeService', () => { getWorkspaceSession, getWorkspaceSessionHostIds: () => ['local'] } as never) + expect(getWorkspaceSession).toHaveBeenCalledExactlyOnceWith('local') + getWorkspaceSession.mockClear() runtime.syncWindowGraph(1, { tabs: [], @@ -667,6 +669,8 @@ describe('OrcaRuntimeService', () => { getWorkspaceSession, getWorkspaceSessionHostIds: () => ['local'] } as never) + expect(getWorkspaceSession).toHaveBeenCalledExactlyOnceWith('local') + getWorkspaceSession.mockClear() runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts index 16bebad7f8c..ffa00c6ef8c 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts @@ -256,6 +256,8 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), getWorkspaceSession } as never) + expect(getWorkspaceSession).toHaveBeenCalledExactlyOnceWith('local') + getWorkspaceSession.mockClear() runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) expect(getWorkspaceSession).toHaveBeenCalledTimes(2) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 4cc5745b2fb..263fa3e3ffb 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,3 +1,5 @@ +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import { agentSessionRecordFixture } from '../../../native-chat/agent-session-record-test-fixture' import type { AgentSessionExecutionLocation } from '../../../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' @@ -10,6 +12,23 @@ import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-ag import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { codexDispatchRejection } from '../../../codex/codex-structured-turn-end-settlement' +type WorkerHost = Pick< + StructuredAgentSessionHost, + | 'hasSession' + | 'getSessionTabId' + | 'notifySessionTabHidden' + | 'setSessionTabVisibility' + | 'close' + | 'observeJournalPublications' +> & { + deps: { + store: Pick< + StructuredAgentSessionHost['deps']['store'], + 'requireWritableRecord' | 'getConversation' | 'getSessionTabId' | 'isSessionTabRetired' + > + } +} + const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -35,19 +54,32 @@ function installHost( wslDistro: null } ) { + const hidden = new Set() + const record = (sessionId: string) => agentSessionRecordFixture({ sessionId, location }) const dispose = vi.fn() const observeJournalPublications = vi.fn(() => dispose) hostRef.current = { - setSessionTabVisibility: async () => {}, + hasSession: () => false, + getSessionTabId: (id: string) => (hidden.has(id) ? null : `agent-session:${id}`), + notifySessionTabHidden: () => {}, + setSessionTabVisibility: async (id: string, visible: boolean) => { + if (visible) { + hidden.delete(id) + } else { + hidden.add(id) + } + }, close: async () => {}, deps: { store: { - requireWritableRecord: (sessionId: string) => - agentSessionRecordFixture({ sessionId, location }) + requireWritableRecord: record, + getConversation: (id: string) => agentSessionConversationView(record(id)), + getSessionTabId: (id: string) => (hidden.has(id) ? null : `agent-session:${id}`), + isSessionTabRetired: (id: string) => hidden.has(id) } }, observeJournalPublications - } + } satisfies WorkerHost return { observeJournalPublications, dispose } } diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 86d828e6e18..34708f9475e 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -1,3 +1,7 @@ +import { + closeStructuredAgentSessionChild, + type StructuredAgentSessionCloseOptions +} from '../../agent-session/structured-agent-session-close' /** Create structured workers directly; attach is not readiness, so hold the preamble until the agent takes it. * Open dispatches retain agents through the idle sweep; settled workers rest and restart on mail. The preamble is a turn, never keystrokes. */ @@ -156,20 +160,18 @@ function structuredCreateMayHaveCommitted( * Keep the tab reachable if close throws. Later authority/preamble failures also use this cleanup. */ export async function discardStructuredWorkerSession( sessionId: string, - runtime: Pick + runtime: NonNullable ): Promise { const host = getStructuredAgentSessionHost() if (!host) { return } - try { - await host.setSessionTabVisibility?.(sessionId, false) - await host.close(sessionId, 'evict') - } catch (error) { + const outcome = await closeStructuredAgentSessionChild(sessionId, { runtime }) + if (!outcome.stopped) { console.warn( '[orchestration] failed to discard a half-started structured worker', sessionId, - error + outcome.reason ) return } diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts index 6a997683614..ebf36064e70 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import { hostTestState } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { agentSessionLeaseFixture, @@ -54,7 +55,19 @@ function installHost() { vi.spyOn(host, 'close').mockImplementation(async (sessionId) => { closed.push(sessionId) }) - vi.spyOn(host, 'hasSession').mockReturnValue(true) + vi.spyOn(host, 'hasSession').mockImplementation((id) => !closed.includes(id)) + vi.spyOn(store, 'getConversation').mockImplementation((id) => + agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: id, + claimStatus: closed.includes(id) ? 'released' : 'live', + ownerProcess: null, + reservedSpawnToken: null + }) + ) + ) + ) vi.spyOn(host, 'subscribe').mockResolvedValue(() => {}) vi.spyOn(store, 'requireWritableRecord').mockImplementation((sessionId) => agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, runtimeFence: 1 })) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index d77072f717f..0cd73e4093f 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 { projectSessionTabFileHosts } from './session-tab-file-host-projection' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' -import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { prepareStructuredTabsIfSupported } from './structured-session-tab-restore' import { createSessionTabsTabDeltaEncoder } from '../../../../shared/session-tabs-tab-delta' type SessionTabsInventory = { @@ -236,7 +236,7 @@ export async function subscribeSessionTabsInventory( let collected: Awaited> | undefined try { // Register before restore so unsubscribe/socket close can find the stream during its awaits. - const restoring = restoreStructuredTabsIfSupported(context) + const restoring = prepareStructuredTabsIfSupported(context) if (restoring) { await restoring if (closed) { diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 7a6dc2494ec..39db5212d83 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -18,7 +18,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 { prepareStructuredTabsIfSupported } from './structured-session-tab-restore' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -31,7 +31,7 @@ export const SESSION_TAB_METHODS = [ permission: 'workspace', params: WorktreeTabSelector, handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { - await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) + await prepareStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) return projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId, { includeInitialSurfaceLaunch: true @@ -46,7 +46,7 @@ export const SESSION_TAB_METHODS = [ permission: 'workspace', params: null, handler: async (_params, context) => { - await restoreStructuredTabsIfSupported(context) + await prepareStructuredTabsIfSupported(context) return listSessionTabsInventory(context) } }), @@ -162,7 +162,7 @@ export const SESSION_TAB_METHODS = [ register(explicitWorktreeId) } try { - await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) + await prepareStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) if (released) { return } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-saved-status-rpc.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-saved-status-rpc.test.ts new file mode 100644 index 00000000000..87538de8563 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-saved-status-rpc.test.ts @@ -0,0 +1,77 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + attach, + hostTestState +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import * as journalFactory from '../../../native-chat/agent-session-journal/journal-store-factory' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_STATUS_METHODS } from './structured-agent-session-status-stream' + +afterEach(() => { + setStructuredAgentSessionHost(null) + vi.restoreAllMocks() +}) + +it.each(['mobile', 'runtime'] as const)( + 'gives a first %s connection its saved snapshot without opening history', + async (clientKind) => { + await attach() + const { host: previous, store } = hostTestState() + await previous.flushAllStreamedEvents() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lastTurn: { state: 'settled', outcome: 'success', at: 100 } + })) + await store.setSessionTabVisibility(SESSION, true) + const host = new StructuredAgentSessionHost(previous.deps) + setStructuredAgentSessionHost(host) + const open = vi.spyOn(journalFactory, 'openAgentSessionJournal') + const emit = vi.fn() + const cleanups: (() => void)[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this RPC reads only startup preparation and stream cleanup from the runtime. + const runtime = { + getRuntimeId: () => 'restarted-orcad', + getClientSettings: () => ({}), + restoreStructuredAgentSessionTabs: () => host.seedSessionTabs(), + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => cleanups.push(cleanup), + cleanupSubscription: () => cleanups.forEach((cleanup) => cleanup()) + } as unknown as OrcaRuntimeService + try { + const dispatcher = new RpcDispatcher({ + runtime, + methods: STRUCTURED_AGENT_SESSION_STATUS_METHODS + }) + await dispatcher.dispatchStreaming( + { id: 'first', authToken: 'token', method: 'agentSession.subscribeStatus', params: null }, + (raw) => emit(JSON.parse(raw)), + { clientKind, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } + ) + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + type: 'snapshot', + sessions: [ + expect.objectContaining({ + sessionId: SESSION, + turnOutcome: 'success', + statusStartedAt: 100 + }) + ] + }) + }) + ) + expect(open).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(false) + } finally { + cleanups.forEach((cleanup) => cleanup()) + await host.flushAllStreamedEvents() + } + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index f9592695f35..2399eed97dd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -3,7 +3,7 @@ // Session lists read turn state from here instead of replaying transcripts: one stream per client // covers every session, and unlike a transcript subscription it retains none of them. -import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { prepareStructuredTabsIfSupported } from './structured-session-tab-restore' import { defineStreamingMethod, type RpcContext } from '../core' import { requireStructuredHost as requireHost } from './structured-agent-session-gate' import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' @@ -49,7 +49,7 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ permission: 'workspace', params: null, handler: async (_params, ctx, emit) => { - const restoration = restoreStructuredTabsIfSupported(ctx) + const restoration = prepareStructuredTabsIfSupported(ctx) if (restoration) { await restoration } 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 f983a4cc017..41d66b5e4cd 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,9 +1,9 @@ import type { RpcContext } from '../core' import { supportsStructuredAgentSessions } from './structured-agent-session-policy' -/** Restore tabs even for legacy mobile fallback rows; capability gating would leave neither chat nor prompt after restart. - * Cleanly closed chats start no child, and an empty saved inventory never builds the host. */ -export function restoreStructuredTabsIfSupported( +/** Prepare record-only tabs even for legacy mobile fallback rows; capability gating would leave neither chat nor prompt after restart. + * No journal is opened, and an empty saved inventory never builds the host. */ +export function prepareStructuredTabsIfSupported( context: Pick ): Promise | undefined { const shouldRestore = context.clientKind === 'mobile' || supportsStructuredAgentSessions(context) 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 ed8a5a7d36d..ee3c88a5d44 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 @@ -9,6 +9,7 @@ import type { AgentSessionLease } from '../../../../shared/agent-session-record' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { partialRuntimeStore } from '../../runtime-store-partial-fixture' import { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' import type { WorkerTerminalResourceRow } from '../../orchestration/worker-terminal-ownership' @@ -77,7 +78,7 @@ async function runtimeShowingStructuredTab(): Promise<{ runtime: OrcaRuntimeService emit: ReturnType }> { - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) const internal = runtime as unknown as RuntimeInternals internal.ensureStructuredAgentSessionHost = async () => undefined internal.notifyMessageArrived = vi.fn() @@ -294,7 +295,7 @@ describe('structured worker release retires the chat tab', () => { describe('structured worker discard retires the chat tab', () => { it('prunes the tab a half-started worker published', async () => { const { close } = installHost() - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) const internal = runtime as unknown as RuntimeInternals internal.ensureStructuredAgentSessionHost = async () => undefined let createdSessionId = '' diff --git a/src/main/runtime/runtime-agent-orchestration-projection.test.ts b/src/main/runtime/runtime-agent-orchestration-projection.test.ts index 4b12e182578..60e3927d298 100644 --- a/src/main/runtime/runtime-agent-orchestration-projection.test.ts +++ b/src/main/runtime/runtime-agent-orchestration-projection.test.ts @@ -750,6 +750,7 @@ it('reads reopened/backfilled assignments and committed clear facts from the alr const before = runtime['agentOrchestrationProjection'].buildByPaneKey() expect(before?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe(chatRow(SESSION_X)) await openAgentSessionRecordStoreOnce({ + legacySessionTabIds: [], stateDirectory: directory, hostId: 'local', logger: recordingStructuredAgentSessionLogger().logger diff --git a/src/main/runtime/runtime-mobile-structured-tab-status.test.ts b/src/main/runtime/runtime-mobile-structured-tab-status.test.ts index b8e9d6b2b23..958563d9958 100644 --- a/src/main/runtime/runtime-mobile-structured-tab-status.test.ts +++ b/src/main/runtime/runtime-mobile-structured-tab-status.test.ts @@ -3,8 +3,15 @@ import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subj import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { AgentHookServer } from '../agent-hooks/server' +import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps' +import { AgentStatusObservedPaneIdentities } from './agent-status-observed-pane-identity' import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection' import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import type { AgentSessionLastTurn } from '../../shared/agent-session-last-turn' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -42,6 +49,45 @@ function projectChatTab(store: AgentHookServer, worktree: string) { } describe('a chat tab on the phone', () => { + it.each([ + [{ state: 'settled', outcome: 'success', at: 22_000 }, 'success'], + [{ state: 'open', at: 22_000 }, 'cancellation'], + [{ state: 'settled', outcome: 'cancellation', at: 22_000 }, 'cancellation'], + [{ state: 'settled', outcome: 'failure', at: 22_000 }, 'failure'], + [{ state: 'settled', outcome: 'interruption', at: 22_000 }, 'interruption'] + ] satisfies [AgentSessionLastTurn, string][])( + 'carries saved %j through the trusted status sink to the phone', + (lastTurn, outcome) => { + const store = new AgentHookServer() + const record = agentSessionConversationView({ + ...agentSessionRecordFixture(), + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'folder' + }, + lastTurn + }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map(), + getConversation: () => record, + now: () => 99_000, + logger: createStructuredAgentSessionLogger(), + statusSink: () => + agentHookRuntimeDeps(store, new AgentStatusObservedPaneIdentities(), () => ({})) + .structuredAgentStatusSink + }) + feed.publishSavedGroup((publish) => publish(record)) + expect(projectChatTab(store, WORKTREE_ID)).toMatchObject({ + type: 'agent-session', + chatStatus: { state: 'done', mainAgent: { state: 'done', outcome, stateStartedAt: 22_000 } } + }) + expect(projectChatTab(store, 'other-folder')).not.toHaveProperty('chatStatus') + } + ) + it("carries the host's structured row for its session, and only in that workspace", () => { const store = new AgentHookServer() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields a structured status summary needs for ingest. diff --git a/src/main/runtime/session-tabs/restored-chat-tab-changes.ts b/src/main/runtime/session-tabs/restored-chat-tab-changes.ts index 417acb4726c..a5e716c22cc 100644 --- a/src/main/runtime/session-tabs/restored-chat-tab-changes.ts +++ b/src/main/runtime/session-tabs/restored-chat-tab-changes.ts @@ -1,7 +1,11 @@ +import { OrcaRuntimeWithCloseMobileSessionTab } from '../session-lifecycle/close/close-mobile-session-tab' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { retireStructuredAgentSessionTabFrom } from '../agent-session/structured-agent-session-tab-retirement' import { chatTabChangesMatchingIndex } from '../agent-session/structured-agent-session-tab-index-seed' import { chatTabId, type ChatTabChange } from '../agent-session/agent-session-tab-layout' import { structuredSessionTabRetired } from '../structured-worker-custody' -import type { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' type ChatTabOpen = Extract['open'] @@ -9,7 +13,8 @@ type ChatTabOpen = Extract['open'] export function restoredChatTabChanges( savedTabsByWorktree: Parameters[0], host: ReturnType, - restored: readonly { workspaceId: string; sessionId: string; agent: ChatTabOpen['agent'] }[] + restored: readonly { workspaceId: string; sessionId: string; agent: ChatTabOpen['agent'] }[], + live: (id: string) => boolean = () => false ): Map { return chatTabChangesMatchingIndex( savedTabsByWorktree, @@ -25,6 +30,68 @@ export function restoredChatTabChanges( (sessionId) => !!host && structuredSessionTabRetired(host, sessionId) && + !live(sessionId) && !host.deps?.store?.isSessionUnreadable?.(sessionId) ) } + +export abstract class OrcaRuntimeWithStructuredSessionTabProjection extends OrcaRuntimeWithCloseMobileSessionTab { + protected passiveStructuredSessionTabChangeEligible(change: ChatTabChange): boolean { + const host = getStructuredAgentSessionHost() + if ('open' in change) { + return host?.isSessionTabVisible(change.open.sessionId) ?? false + } + return ( + !!host && + structuredSessionTabRetired(host, change.close) && + !host.deps.store.isSessionUnreadable(change.close) && + !this.hasCurrentLocalStructuredSessionTab(change.close) + ) + } + + /** Prune runtime snapshots after proven close/release; durable visibility alone leaves dead chat rows. Renderer-free for startup reconciliation and orchestration cleanup. */ + retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean { + let retired = false + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (this.workspaceSessions.tryGetHostId(worktreeId) !== LOCAL_EXECUTION_HOST_ID) { + continue + } + const nextSnapshot = retireStructuredAgentSessionTabFrom(snapshot, sessionId) + if (!nextSnapshot) { + continue + } + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + retired = true + } + return retired + } + + private readonly localStructuredTabs = new WeakMap< + RuntimeMobileSessionTabsSnapshot, + ReadonlySet + >() + + protected hasCurrentLocalStructuredSessionTab(sessionId: string): boolean { + const record = getStructuredAgentSessionHost()?.deps.store.getConversation(sessionId) + if ( + !record || + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + this.workspaceSessions.tryGetHostId(record.location.workspaceId) !== LOCAL_EXECUTION_HOST_ID + ) { + return false + } + const snapshot = this.mobileSessionTabsByWorktree.get(record.location.workspaceId) + if (!snapshot) { + return false + } + let ids = this.localStructuredTabs.get(snapshot) + if (!ids) { + ids = new Set( + snapshot.tabs.flatMap((tab) => (tab.type === 'agent-session' ? [tab.sessionId] : [])) + ) + this.localStructuredTabs.set(snapshot, ids) + } + return ids.has(sessionId) + } +} diff --git a/src/main/runtime/session-tabs/structured-session-tab-close.ts b/src/main/runtime/session-tabs/structured-session-tab-close.ts index d0857d2c1f5..267155a0dc4 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-close.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-close.ts @@ -1,7 +1,8 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as BrowserOwnership } from './mobile-session-publication-preservation' import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout as PruneMobileSessionTabGroupLayoutMethods } from '../session-lifecycle/projection/prune-mobile-session-tab-group-layout' import type { RuntimeHeadlessTabGroups as HeadlessTabGroupsMethods } from './headless-tab-group-moves' -import { OrcaRuntimeWithCloseMobileSessionTab } from '../session-lifecycle/close/close-mobile-session-tab' +import { OrcaRuntimeWithStructuredSessionTabProjection } from './restored-chat-tab-changes' import type { BrowserTabInfo, RuntimeMobileSessionAgentTab, @@ -16,7 +17,6 @@ import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' import { applyBrowserSessionTabSelection } from '../browser-session-tab-selection-snapshot' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionCloseCause } from '../../native-chat/agent-session-wire/structured-agent-session-host-lifetime' -import { retireStructuredAgentSessionTabFrom } from '../agent-session/structured-agent-session-tab-retirement' import { chatTabId, withChatTabChanges, @@ -28,11 +28,10 @@ import type { WindowLayoutCommand } from '../runtime-layout-apply' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' import { savedSessionId } from '../saved-structured-agent-session-restoration' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { structuredSessionTabRetired } from '../structured-worker-custody' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { +export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithStructuredSessionTabProjection { protected abstract isHeadlessOwnedMobileBrowserTab( ...args: Parameters ): ReturnType @@ -80,20 +79,6 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca } } - /** Prune runtime snapshots after proven close/release; durable visibility alone leaves dead chat rows. Renderer-free for startup reconciliation and orchestration cleanup. */ - retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean { - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - const nextSnapshot = retireStructuredAgentSessionTabFrom(snapshot, sessionId) - if (!nextSnapshot) { - continue - } - this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - return true - } - return false - } - // Refused echo closes need a newer snapshot so clients restore the still-live mirror they already pruned. protected republishMobileSessionTabsSnapshot(worktreeId: string): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) @@ -273,18 +258,22 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca if (!host || !structuredSessionTabRetired(host, sessionId)) { return } + // Retire old presentation before it could protect itself from passive cleanup. + this.retireStructuredAgentSessionTabFromSnapshot(sessionId) + const eligible = () => this.passiveStructuredSessionTabChangeEligible({ close: sessionId }) // Resolve chat workspaces when the close queue runs, including opens queued before it. if (ORCA_LAYOUT_OWNER_IS_MAIN) { this.queueChatTabChanges(() => this.worktreesShowingSavedChat(sessionId).map((worktreeId) => ({ worktreeId, - change: { close: sessionId } + change: { close: sessionId }, + eligible })) ) return } for (const worktreeId of this.worktreesShowingSavedChat(sessionId)) { - this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }]) + this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }], eligible) } } @@ -296,10 +285,20 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca } // A window saves its own layout whole, chat tabs included; only a host with none saves them here. - protected changeWindowlessChatTabs(worktreeId: string, changes: readonly ChatTabChange[]): void { + protected changeWindowlessChatTabs( + worktreeId: string, + changes: readonly ChatTabChange[], + eligible?: (change: ChatTabChange) => boolean + ): void { // Main-owned layouts place chat tabs with or without a window. if (ORCA_LAYOUT_OWNER_IS_MAIN) { - this.queueChatTabChanges(() => changes.map((change) => ({ worktreeId, change }))) + this.queueChatTabChanges(() => + changes.map((change) => ({ + worktreeId, + change, + ...(eligible ? { eligible: () => eligible(change) } : {}) + })) + ) return } const session = this.getAvailableAuthoritativeWindow() @@ -311,7 +310,7 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca this.getWorkspaceSessionHostIdForWorktree(worktreeId), session, worktreeId, - changes + eligible ? changes.filter(eligible) : changes ) if (next) { this.setWorkspaceSessionForWorktree(worktreeId, next) diff --git a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts index a33e95c92e7..a51fa220a31 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts @@ -2,9 +2,7 @@ import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileS import { defaultAgentChatLabel } from '../../../shared/agent-session/agent-session-chat-label' import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './structured-session-create-support' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' -import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent-session-restoration' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import { seedStructuredAgentSessionTabIndex } from '../agent-session/structured-agent-session-tab-index-seed' import { restoredChatTabChanges } from './restored-chat-tab-changes' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import type { @@ -45,15 +43,15 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten protected async restoreStructuredAgentSessionTabsOnce(): Promise { await this.prepareStructuredAgentSessionStartupRestoration() const host = getStructuredAgentSessionHost() - const persistedVisibleIndex = - typeof host?.getPersistedVisibleSessionTabIndex === 'function' - ? host.getPersistedVisibleSessionTabIndex() - : { present: false, sessionIds: [] } - const profileIds = collectSavedStructuredAgentSessionIds( - this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null - ) - const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - await host?.restoreReadableSessions(targets) + if (host?.getPersistedVisibleSessionTabIndex?.().present !== true) { + await host?.deps?.store?.initializeSessionTabs?.().catch((error: unknown) => + host.deps.logger.warn('recording restored chat tabs failed', { + scope: 'tab-index-seed', + error + }) + ) + } + await host?.seedSessionTabs() for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -69,21 +67,21 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten } return [{ ...session, agent: session.agent, sessionId }] }) - await seedStructuredAgentSessionTabIndex( - host, - targets, - restored.map((session) => session.sessionId) - ) const changes = restoredChatTabChanges( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {}, host, - restored + restored, + (id) => this.hasCurrentLocalStructuredSessionTab(id) ) for (const [worktreeId, worktreeChanges] of changes) { - this.changeWindowlessChatTabs(worktreeId, worktreeChanges) + this.changeWindowlessChatTabs(worktreeId, worktreeChanges, (change) => + this.passiveStructuredSessionTabChangeEligible(change) + ) } for (const session of restored) { - this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) + if (host?.isSessionTabVisible(session.sessionId)) { + 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. diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 41fc4362489..ffff4ee7979 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -55,6 +55,7 @@ const runtime = { openAgentSessionRecordStore: async () => ( await openAgentSessionRecordStoreOnce({ + legacySessionTabIds: [], stateDirectory, hostId: 'local', logger: createStructuredAgentSessionLogger() diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index c1abadcfbc9..a5e0322b60d 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -1,3 +1,6 @@ +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' // Journal-open refusal leaves the file intact and other runtime work available. import { createHash } from 'node:crypto' @@ -317,11 +320,17 @@ describe('startup and other non-chat work without a structured host', () => { const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) await unlink(journalDatabasePath(root)) + await seedTestAgentSessionRecordStore(root, { + records: [agentSessionRecordFixture({ sessionId: 'claude-1' })], + sessionTabs: [{ tabId: structuredAgentSessionTabId('claude-1'), sessionId: 'claude-1' }] + }) const host = await install() - vi.spyOn(host, 'listSessionTabs').mockReturnValue([ - { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' } - ]) - vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + expect(host.deps.store.getConversation('claude-1')).toMatchObject({ sessionId: 'claude-1' }) + expect(host.getPersistedVisibleSessionTabIndex()).toEqual({ + present: true, + sessionIds: ['claude-1'] + }) + expect(host.isSessionTabVisible('claude-1')).toBe(true) const frames = await listInventory(runtime) unsubscribe() diff --git a/src/main/runtime/structured-agent-session-mobile-restore.test.ts b/src/main/runtime/structured-agent-session-mobile-restore.test.ts index d85e15be58c..d8ec3a3cd2e 100644 --- a/src/main/runtime/structured-agent-session-mobile-restore.test.ts +++ b/src/main/runtime/structured-agent-session-mobile-restore.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from '../native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support' import { describe, expect, it } from 'vitest' import { hostTestState, @@ -102,7 +103,7 @@ describe('prompt delivery after host recovery', () => { const stop = wire(host, restarted) try { await host.reconcileRestartLeases() - await host.restoreReadableSessions() + await openTestChats(host) const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok).toBe(true) expect( @@ -117,7 +118,7 @@ describe('prompt delivery after host recovery', () => { new MobileNotificationDismissalStore(h.root).liveDeliveries(identity.notificationId) ).toEqual([]) expect(restarted.reconcileDismissedPushes([identity])).toEqual([identity]) - await host.restoreReadableSessions() + await openTestChats(host) expect(events.filter((event) => event.type === 'dismiss')).toHaveLength(1) } finally { stop() diff --git a/src/main/runtime/structured-agent-session-runtime-contract.ts b/src/main/runtime/structured-agent-session-runtime-contract.ts index 1f345e5817d..0ccb566f4c1 100644 --- a/src/main/runtime/structured-agent-session-runtime-contract.ts +++ b/src/main/runtime/structured-agent-session-runtime-contract.ts @@ -13,6 +13,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session- export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & { /** Host state root. The record store and the journal database both hang off it. */ stateDirectory: string + legacySessionTabIds?: readonly string[] /** Execution host this runtime *is*. A record pinned elsewhere is not ours to * probe and not ours to spawn for. */ hostId: string diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 7ba1a34ddef..ee230e0a8a4 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -1,3 +1,4 @@ +import { openTestChats } from '../native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -226,7 +227,7 @@ describe('structured session runtime provider-exit wiring', () => { resolveEnvironment: async () => ({ PATH: process.env.PATH }), ...scriptedCodexTransport(openConnection) }) - await restarted.restoreReadableSessions() + await openTestChats(restarted) const history = await restarted.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index bfa379e2d15..62b476f42c6 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -113,6 +113,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise void; profileChats?: string[] } = {}) { const log = recordingStructuredAgentSessionLogger() - 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({ - logger: log.logger, - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }) - }) - 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 - })) - } - }) + const profile: WorkspaceSessionState = { + 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 + })) + } } + class StartupRuntime extends OrcaRuntimeService { + protected override hasPersistedStructuredAgentSessionStore() { + return true + } + protected override async refreshMobileSessionPtyRecords() { + return new Set() + } + override async ensureStructuredAgentSessionHost() { + await ensureStructuredAgentSessionHost({ + logger: log.logger, + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + legacySessionTabIds: this.legacyStructuredSessionTabIds, + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) + }) + options.afterInstall?.() + } + } + const runtime = new StartupRuntime(partialRuntimeStore({ getWorkspaceSession: () => profile })) return { runtime, log, - published: () => internal.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] + published: () => runtime.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] } } @@ -226,7 +231,7 @@ async function expectHistory(sessionId: string): Promise { function spyOnTabWrites() { return { visibility: vi.spyOn(AgentSessionRecordStore.prototype, 'setSessionTabVisibility'), - seed: vi.spyOn(AgentSessionRecordStore.prototype, 'showSessionTabs') + seed: vi.spyOn(AgentSessionRecordStore.prototype, 'initializeSessionTabs') } } @@ -235,6 +240,208 @@ async function tabIndexOnDisk(): Promise { } describe('restoring the chat tabs open at quit', () => { + it('keeps recorded Codex queue admission through startup and physical reopen until Steer', async () => { + const sessionId = 'codex_921935f3_cbca_41e4_8b82_220245b3ecd4' + const epoch = '13c4e01d-1d1f-41bc-932a-629bf803c4a1' + const messageId = '1793285151845-19784765711d4fa6aa7a71b754b8574d' + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply exactly QA3231_CODEX_KEPT_REPLY1_OK' }] + } + // codex-main79-immutable-raw.json SHA25645767f7ef2a1ae7d44767fd4c79b4d9717c73f3548dc15205449a5812e4ba067: original rows from the stopped upgraded DB. + // Select the committed admission before seq3 handover, not an observed kept card or pre-upgrade DB snapshot. + const recordedRows = [ + { + seq: 1, + ts: 1793285143624, + json: '{"kind":"epoch","reason":"session_created","providerHandle":{"kind":"opaque","agent":"codex","value":"pending"},"v":2,"epoch":"13c4e01d-1d1f-41bc-932a-629bf803c4a1","seq":1,"fence":0,"ts":1793285143624}' + }, + { + seq: 2, + ts: 1793285151851, + json: '{"kind":"submission","clientMessageId":"1793285151845-19784765711d4fa6aa7a71b754b8574d","payloadFingerprint":"8d7b18e93fb35212a3f33ebb3781abf2708cace17a461933ad7c235cef55e7fd","providerHandle":{"kind":"opaque","agent":"codex","value":"pending"},"body":{"kind":"message","role":"user","blocks":[{"type":"text","text":"Reply exactly QA3231_CODEX_KEPT_REPLY1_OK"}]},"v":2,"epoch":"13c4e01d-1d1f-41bc-932a-629bf803c4a1","seq":2,"fence":1,"ts":1793285151851,"handoverRecorded":true,"origin":"client","source":{"kind":"user"},"options":{}}' + } + ] + const record = chatRecord(sessionId, { codex: true }) + // Companion record/profile are fixtures; only these two raw journal rows are recorded state. + await seedProfile([record], { visible: [sessionId], history: [] }) + const database = openTestJournalHostDatabase(root) + publishJournalSessionEpoch( + database.db, + { sessionId, workspaceId: record.location.workspaceId }, + epoch + ) + for (const row of recordedRows) { + insertTestJournalRowJson(database.db, sessionId, row.seq, row.json, row.ts) + } + closeTestJournalHostDatabase(root) + + const installs: { + host: StructuredAgentSessionHost + provider: ReturnType + }[] = [] + const installProvider = () => { + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('production runtime did not install its host') + } + if (installs.some((installed) => installed.host === host)) { + return + } + const provider = createQueuedRigProvider(host.deps.store, { restartable: true }, () => host) + Object.assign(host.deps.adapter, provider.adapter, { disposeSession: provider.closeSession }) + installs.push({ host, provider }) + } + const current = () => { + const installed = installs.at(-1) + if (!installed) { + throw new Error('no installed test provider') + } + return installed + } + const clock = createStructuredSessionObservationClock(() => current().host, sessionId) + const expectWaitingCard = async (host: StructuredAgentSessionHost) => { + const history = await host.history({ sessionId, direction: 'tail' }) + if (!history.ok) { + throw new Error('recorded conversation history refused') + } + expect(history.page.queuedMessages).toEqual([ + { messageId, position: 0, state: 'waiting', body } + ]) + const original = (await host.journalSnapshot(sessionId)).submissions + expect(original).toHaveLength(1) + expect(original[0]).toMatchObject({ + clientMessageId: messageId, + acceptedSequence: 2, + dispatchState: 'rejected', + keptAsQueuedMessageId: messageId, + handoverRecorded: true, + origin: 'client', + source: { kind: 'user' }, + options: {} + }) + expect(original[0]?.handedOverAt).toBeUndefined() + } + const now = 1_793_285_160_000 + try { + const opens = vi.spyOn(journalFactory, 'openAgentSessionJournal') + const firstRuntime = startupRuntime({ + profileChats: [sessionId], + afterInstall: installProvider + }) + await firstRuntime.runtime.restoreStructuredAgentSessionTabs() + const first = current() + expect(firstRuntime.published().map((tab) => tab.id)).toEqual([`agent-session:${sessionId}`]) + expect(opens).not.toHaveBeenCalled() + expect(first.provider.starts).not.toHaveBeenCalled() + expect(first.provider.dispatch).not.toHaveBeenCalled() + await expect(first.host.revealSession(sessionId)).resolves.toMatchObject({ + sessionId, + readable: true + }) + expect(opens).toHaveBeenCalledOnce() + await expectWaitingCard(first.host) + clock.start() + vi.setSystemTime(now) + await clock.observe(100) + expect(first.provider.starts).not.toHaveBeenCalled() + expect(first.provider.dispatch).not.toHaveBeenCalled() + + const firstDatabase = first.host.deps.journalDatabase + clock.restore() + await stopStructuredAgentSessionRuntime() + expect(firstDatabase.isClosed).toBe(true) + closeTestJournalHostDatabases() + opens.mockClear() + const nextRuntime = startupRuntime({ + profileChats: [sessionId], + afterInstall: installProvider + }) + await nextRuntime.runtime.restoreStructuredAgentSessionTabs() + const next = current() + expect(next.host).not.toBe(first.host) + expect(next.host.deps.store).not.toBe(first.host.deps.store) + expect(next.host.deps.journalDatabase).not.toBe(firstDatabase) + expect(nextRuntime.published().map((tab) => tab.id)).toEqual([`agent-session:${sessionId}`]) + expect(opens).not.toHaveBeenCalled() + expect(next.provider.starts).not.toHaveBeenCalled() + expect(next.provider.dispatch).not.toHaveBeenCalled() + await expect(next.host.revealSession(sessionId)).resolves.toMatchObject({ + sessionId, + readable: true + }) + expect(opens).toHaveBeenCalledOnce() + await expectWaitingCard(next.host) + clock.start() + vi.setSystemTime(now) + await clock.observe(100) + expect(next.provider.starts).not.toHaveBeenCalled() + expect(next.provider.dispatch).not.toHaveBeenCalled() + + const operationId = mintAgentSessionOperationId(now) + const liveRecord = next.host.deps.store.getRecord(sessionId) + if (!liveRecord) { + throw new Error('recorded conversation disappeared') + } + const sent = await next.host.queuedMessageSend( + { callerKey: 'client-1' }, + { + envelope: { + sessionId, + clientOperationId: operationId, + expectedRuntimeFence: liveRecord.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.queuedMessageSend', + sessionId, + fields: { messageId } + }) + }, + messageId + } + ) + expect(sent).toMatchObject({ + ok: true, + value: { clientMessageId: operationId, submission: { queuedMessageId: messageId } } + }) + expect(operationId).not.toBe(messageId) + await vi.waitFor(() => expect(next.provider.dispatch).toHaveBeenCalledTimes(1)) + expect(next.provider.starts).toHaveBeenCalledOnce() + expect(next.provider.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId, + clientMessageId: operationId, + body, + executionOptions: {} + }) + ) + const delivered = await next.host.history({ sessionId, direction: 'tail' }) + if (!delivered.ok) { + throw new Error('delivered conversation history refused') + } + expect(delivered.page.queuedMessages).toEqual([]) + expect( + (await next.host.journalSnapshot(sessionId)).submissions.filter( + (entry) => entry.queuedMessageId === messageId + ) + ).toEqual([ + expect.objectContaining({ + clientMessageId: operationId, + queuedMessageId: messageId, + origin: 'client', + options: {} + }) + ]) + await clock.observe(100) + expect(next.provider.dispatch).toHaveBeenCalledTimes(1) + expect(first.provider.dispatch).not.toHaveBeenCalled() + clock.restore() + await stopStructuredAgentSessionRuntime() + expect(next.host.deps.journalDatabase.isClosed).toBe(true) + } finally { + clock.restore() + } + }) it.each([ { store: 'records a newer Orca wrote', newer: true, writesFail: false }, { store: 'a store whose writes keep failing', newer: false, writesFail: true } @@ -384,7 +591,6 @@ describe('restoring the chat tabs open at quit', () => { message: 'recording restored chat tabs failed', fields: { scope: 'tab-index-seed', - sessionIds: [CHAT_A, CHAT_B], error: expect.objectContaining({ message: 'disk I/O error' }) } }) diff --git a/src/main/runtime/structured-agent-session-tab-authority-scale.test.ts b/src/main/runtime/structured-agent-session-tab-authority-scale.test.ts new file mode 100644 index 00000000000..a6603917850 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-authority-scale.test.ts @@ -0,0 +1,203 @@ +import { expect, it, vi } from 'vitest' +import { + AuthorityRuntime, + authorityProfile, + openAuthorityRuntime, + createC, + B +} from './structured-agent-session-tab-authority.test-support' +import * as selection from './saved-structured-agent-session-restoration' +import * as founding from './agent-session-founding-admission' +import * as state from './agent-session-store-state' +import * as rows from './agent-session-record-rows' +import * as journalFactory from '../native-chat/agent-session-journal/journal-store-factory' +import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { closeTestStores } from '../persistence/persistence-test-harness' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { chatTabCommandsSettled } from './session-layout-apply.test-fixture' + +// CLI capability probing is outside tab authority and must not start a real CLI in this fixture. +vi.mock('../claude/claude-cli-flag-prewarm', () => ({ + prewarmClaudeCliFlags: async () => undefined +})) + +it.each([10, 100])( + 'counts actual capture/materialization/projection work for %i selected chats', + async (n) => { + const ids = Array.from({ length: n }, (_, i) => `selected-${i.toString().padStart(5, '0')}`) + const profile = await authorityProfile(ids) + const capture = vi.spyOn(selection, 'collectSavedStructuredAgentSessionIds') + const runtime = new AuthorityRuntime(profile.profile) + expect(capture).toHaveBeenCalledOnce() + expect(runtime.capturedSelection()).toEqual(ids) + const rig = await openAuthorityRuntime(profile, runtime) + const checks = { absent: 0, present: 0 } + const has = state.hasAgentSessionConversation + const checked = vi + .spyOn(state, 'hasAgentSessionConversation') + .mockImplementation((draft, id) => { + checks[draft.sessionTabs === null ? 'absent' : 'present']++ + return has(draft, id) + }) + const keyed = vi.spyOn(rig.store, 'isSessionTabVisible') + const index = vi.spyOn(rig.store, 'getVisibleSessionTabIndex') + const initialize = vi.spyOn(rig.store, 'initializeSessionTabs') + const writer = vi.spyOn(rows, 'writeAgentSessionStoreRows') + const opener = vi.spyOn(journalFactory, 'openAgentSessionJournal') + const sink = vi.spyOn(rig.host.deps.statusSink!, 'publish') + const turnWriter = vi.spyOn(rig.store, 'transitionHandoff') + await rig.runtime.restoreStructuredAgentSessionTabs() + await chatTabCommandsSettled() + expect(checks.absent).toBe(n) + expect(checks.present).toBe(6 * n) + expect(initialize).toHaveBeenCalledOnce() + const batches = writer.mock.calls.filter(([, write]) => write.sessionTabs !== null) + expect(batches).toHaveLength(1) + expect(batches[0]![1].sessionTabs?.tabs).toHaveLength(n) + expect(batches[0]![1].records.upsert).toEqual([]) + // Existing record-only lease reconciliation is separate from visibility materialization. + expect(writer.mock.calls.filter(([, write]) => write.sessionTabs === null)).toHaveLength(1) + expect(writer.mock.calls[0]![1].records.upsert).toHaveLength(n) + expect(sink).toHaveBeenCalledTimes(n) + expect(rig.host.listSessionTabs()).toHaveLength(n) + expect(keyed.mock.calls.length).toBeLessThanOrEqual(10 * n) + expect(index.mock.calls.length).toBeLessThanOrEqual(2) + expect(opener).not.toHaveBeenCalled() + expect(turnWriter).not.toHaveBeenCalled() + expect(capture).toHaveBeenCalledOnce() + const snapshot = rig.runtime.mobileSessionTabsByWorktree.get(rig.workspace)! + const enumerate = vi.spyOn(snapshot.tabs, 'flatMap') + for (const id of ids) { + expect(rig.runtime.live(id)).toBe(true) + } + expect(enumerate).toHaveBeenCalledOnce() + const replacement = rig.runtime.storeMobileSessionSnapshot(rig.workspace, { + ...snapshot, + tabs: [...snapshot.tabs] + }) + const refreshed = vi.spyOn(replacement.tabs, 'flatMap') + for (const id of ids) { + expect(rig.runtime.live(id)).toBe(true) + } + expect(refreshed).toHaveBeenCalledOnce() + rig.runtime.mobileSessionTabsByWorktree.delete(rig.workspace) + for (const id of ids) { + expect(rig.runtime.live(id)).toBe(false) + } + expect(refreshed).toHaveBeenCalledOnce() + checked.mockClear() + initialize.mockClear() + writer.mockClear() + await rig.store.setConversationName(ids[0]!, 'renamed') + await rig.runtime.restoreStructuredAgentSessionTabs() + expect(checked).not.toHaveBeenCalled() + expect(initialize).not.toHaveBeenCalled() + expect(writer.mock.calls.every(([, write]) => write.sessionTabs === null)).toBe(true) + expect(capture).toHaveBeenCalledOnce() + } +) + +it.each([10, 100])( + 'prepare and actual direct/receipt stages rederive %i captured rows; metadata does not', + async (n) => { + const ids = Array.from({ length: n }, (_, i) => `selected-${i.toString().padStart(5, '0')}`) + for (const first of [false, true]) { + const profile = await authorityProfile(ids) + const rig = await openAuthorityRuntime(profile) + const checked = vi.spyOn(state, 'hasAgentSessionConversation') + await rig.store.setConversationName(ids[0]!, 'metadata only') + expect(checked).not.toHaveBeenCalled() + const attempts: { checks: number; published: boolean; writes: number }[] = [] + const writer = vi.spyOn(rows, 'writeAgentSessionStoreRows') + const commit = founding.commitAgentSessionFounding + const attempt = vi + .spyOn(founding, 'commitAgentSessionFounding') + .mockImplementation((draft, request, base) => { + const before = checked.mock.calls.length + const result = commit(draft, request, base) + attempts.push({ + checks: checked.mock.calls.length - before, + published: rig.store.getVisibleSessionTabIndex().present, + writes: writer.mock.calls.length + }) + return result + }) + expect(await createC(rig, first)).toMatchObject({ ok: true }) + expect(attempts).toEqual([ + { checks: 2 * n + 1, published: false, writes: 0 }, + { checks: 2 * n + 1, published: false, writes: 0 } + ]) + expect(writer.mock.calls.filter(([, write]) => write.sessionTabs !== null)).toHaveLength(1) + expect(rig.store.listVisibleSessionIds()).toEqual([...ids, 'chat-c-0003']) + attempt.mockRestore() + checked.mockRestore() + writer.mockRestore() + await stopStructuredAgentSessionRuntime() + await closeTestStores() + closeTestJournalHostDatabases() + } + } +) + +it.each([10, 100])( + 'present authority still pays one upfront %i-row capture and no further base traversal or init write', + async (n) => { + const ids = Array.from({ length: n }, (_, i) => `selected-${i.toString().padStart(5, '0')}`) + const profile = await authorityProfile(ids, { visible: [] }) + const capture = vi.spyOn(selection, 'collectSavedStructuredAgentSessionIds') + const runtime = new AuthorityRuntime(profile.profile) + expect(runtime.capturedSelection()).toEqual(ids) + const rig = await openAuthorityRuntime(profile, runtime) + const has = state.hasAgentSessionConversation + let legacyChecks = 0 + vi.spyOn(state, 'hasAgentSessionConversation').mockImplementation((draft, id) => { + if (draft.sessionTabs === null) { + legacyChecks++ + } + return has(draft, id) + }) + const initialize = vi.spyOn(rig.store, 'initializeSessionTabs') + const writer = vi.spyOn(rows, 'writeAgentSessionStoreRows') + await rig.runtime.restoreStructuredAgentSessionTabs() + await rig.store.setConversationName(ids[0]!, 'metadata only') + expect(capture).toHaveBeenCalledOnce() + expect(legacyChecks).toBe(0) + expect(initialize).not.toHaveBeenCalled() + expect(writer.mock.calls.every(([, write]) => write.sessionTabs === null)).toBe(true) + expect(rig.store.listVisibleSessionIds()).toEqual([]) + } +) + +it.each(['receipt-first', 'ordinary-first'] as const)( + 'rebases first-message receipt against actual %s staging order', + async (order) => { + const rig = await openAuthorityRuntime(await authorityProfile()) + const stages: string[] = [] + let hide: Promise | undefined + if (order === 'ordinary-first') { + await rig.store.setSessionTabVisibility(B, false, undefined, () => stages.push('ordinary')) + } + const receipt = rig.store.founding.receipt + vi.spyOn(rig.store.founding, 'receipt').mockImplementation((request) => { + const original = receipt(request) + return { + write: (...args) => { + if (order === 'receipt-first') { + hide = rig.store.setSessionTabVisibility(B, false, undefined, () => + stages.push('ordinary') + ) + } + stages.push('receipt') + original.write(...args) + }, + committed: original.committed + } + }) + expect(await createC(rig, true)).toMatchObject({ ok: true }) + await hide + expect(stages).toEqual( + order === 'receipt-first' ? ['receipt', 'ordinary'] : ['ordinary', 'receipt'] + ) + expect(rig.store.listVisibleSessionIds()).toEqual(['chat-c-0003']) + } +) diff --git a/src/main/runtime/structured-agent-session-tab-authority.test-support.ts b/src/main/runtime/structured-agent-session-tab-authority.test-support.ts new file mode 100644 index 00000000000..785fa60dc73 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-authority.test-support.ts @@ -0,0 +1,251 @@ +import { randomUUID } from 'node:crypto' +import type { MockInstance } from 'vitest' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, vi } from 'vitest' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { createStore, closeTestStores, testState } from '../persistence/persistence-test-harness' +import { initOrcaProfilePaths } from '../orca-profiles/profile-storage-paths' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createTestParams, + CREATE_TEST_CALLER +} from '../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { OrcaRuntimeService } from './orca-runtime' +import { withChatTabChanges } from './agent-session/agent-session-tab-layout' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' +import { chatTabCommandsSettled } from './session-layout-apply.test-fixture' +import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' + +export const B = 'chat-b-0002', + C = 'chat-c-0003', + G = 'chat-gone-0004' +export let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-tab-authority-')) + testState.dir = root + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) +afterEach(async () => { + await chatTabCommandsSettled() + await stopStructuredAgentSessionRuntime() + await closeTestStores() + closeTestJournalHostDatabases() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +export class AuthorityRuntime extends OrcaRuntimeService { + protected override async refreshMobileSessionPtyRecords() { + return new Set() + } + capturedSelection() { + return this.legacyStructuredSessionTabIds + } + live(id: string) { + return this.hasCurrentLocalStructuredSessionTab(id) + } +} + +export async function authorityProfile( + ids: string[] = [B], + options: { visible?: string[]; records?: string[] } = {} +) { + const profile = createStore() + initOrcaProfilePaths() + const group = profile.createProjectGroup({ + name: 'Chats', + parentPath: root, + connectionId: null, + parentGroupId: null, + createdFrom: 'manual' + }) + const folder = profile.createFolderWorkspace({ + projectGroupId: group.id, + folderPath: root, + name: 'Chat owner' + }) + const workspace = folderWorkspaceKey(folder.id) + const records = (options.records ?? ids.filter((id) => id !== G)).map((id) => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: id, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + ) + return { ...record, location: { ...record.location, workspaceId: workspace } } + }) + await seedTestAgentSessionRecordStore(root, { + records, + ...(options.visible + ? { + sessionTabs: options.visible.map((sessionId) => ({ + tabId: structuredAgentSessionTabId(sessionId), + sessionId + })) + } + : {}) + }) + const saved = withChatTabChanges( + 'local', + getDefaultWorkspaceSession(), + workspace, + ids.map((sessionId) => ({ + open: { sessionId, agent: 'codex', tabId: structuredAgentSessionTabId(sessionId) } + })) + ) + if (saved) { + profile.setWorkspaceSession(saved) + } + profile.flushOrThrow() + return { profile, workspace, groupId: group.id, folderId: folder.id } +} + +export type AuthorityProfile = Awaited> + +export type AuthorityRig = AuthorityProfile & { + runtime: AuthorityRuntime + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + acquire: MockInstance +} + +export async function openAuthorityRuntime( + rig: AuthorityProfile, + runtime = new AuthorityRuntime(rig.profile), + installFirst = false +): Promise { + // Exercise the production admission route before the real host installer reuses its slot. + if (installFirst) { + await runtime.ensureStructuredAgentSessionHost() + } + const store = await runtime.openAgentSessionRecordStore() + await runtime.ensureStructuredAgentSessionHost() + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('the production host must be installed') + } + host.stopDelivery() + host.deps.onSessionStatusChanged = undefined + host.deps.conversationNaming = undefined + host.deps.statusSink = { publish: vi.fn(), forget: vi.fn() } + const acquire = vi + .spyOn(host.deps.adapter, 'acquire') + .mockRejectedValue(new Error('no providers in a visibility contract')) + return { ...rig, runtime, store, host, acquire } +} + +export function savedIds(rig: AuthorityProfile) { + return collectSavedStructuredAgentSessionIds(rig.profile.getWorkspaceSession('local')) +} +export function replaceSaved(rig: AuthorityProfile, ids: string[]) { + const next = withChatTabChanges( + 'local', + getDefaultWorkspaceSession(), + rig.workspace, + ids.map((sessionId) => ({ + open: { sessionId, agent: 'codex', tabId: structuredAgentSessionTabId(sessionId) } + })) + ) + rig.profile.setWorkspaceSession(next ?? getDefaultWorkspaceSession()) +} +export async function holdStartup(rig: AuthorityRig) { + const entered = Promise.withResolvers(), + release = Promise.withResolvers() + const capsule = rig.host.deps.recoveryCapsule + if (!capsule) { + throw new Error('the production capsule must be installed') + } + const list = capsule.list.bind(capsule) + vi.spyOn(capsule, 'list').mockImplementationOnce(async (now) => { + entered.resolve() + await release.promise + return list(now) + }) + const restore = rig.runtime.restoreStructuredAgentSessionTabs() + await entered.promise + return async () => { + release.resolve() + await restore + await chatTabCommandsSettled() + } +} +export function rejectVisibility(rig: AuthorityRig) { + const db = rig.host.deps.journalDatabase.db + db.exec( + "CREATE TEMP TRIGGER reject_initial_tabs BEFORE INSERT ON agent_session_store_meta WHEN NEW.key='session_tabs_recorded' BEGIN SELECT RAISE(ABORT, 'visibility rejected'); END" + ) + db.exec( + "CREATE TEMP TRIGGER reject_tab_delta BEFORE DELETE ON agent_session_tabs BEGIN SELECT RAISE(ABORT, 'visibility rejected'); END" + ) + return () => { + db.exec('DROP TRIGGER reject_initial_tabs') + db.exec('DROP TRIGGER reject_tab_delta') + } +} +export async function createC( + rig: AuthorityRig, + firstMessage: boolean, + tabId = structuredAgentSessionTabId(C) +) { + const first = firstMessage + ? { + clientMessageId: 'c-first', + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'C first message' }] + } + } + : undefined + const base = createTestParams(first, { + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') } + }) + const params = createTestParams(first, { + ...base, + location: { ...base.location, workspaceId: rig.workspace }, + surfaceTabId: tabId, + envelope: { + ...base.envelope, + sessionId: C, + clientOperationId: `${Date.now()}-${randomUUID().replaceAll('-', '')}` + } + }) + return rig.host.create(CREATE_TEST_CALLER, params, { + ...(first ? { firstMessage: first } : {}), + publishTab: () => + rig.runtime.publishStructuredAgentSessionTab({ + workspaceId: rig.workspace, + sessionId: C, + agent: 'codex', + activate: true, + tabId + }) + }) +} +export async function physicalReload(rig: AuthorityRig) { + await chatTabCommandsSettled() + await stopStructuredAgentSessionRuntime() + rig.profile.flushOrThrow() + await closeTestStores() + closeTestJournalHostDatabases() + const profile = createStore() + const fresh = await openAuthorityRuntime({ ...rig, profile }, new AuthorityRuntime(profile)) + await fresh.runtime.restoreStructuredAgentSessionTabs() + await chatTabCommandsSettled() + return fresh +} diff --git a/src/main/runtime/structured-agent-session-tab-authority.test.ts b/src/main/runtime/structured-agent-session-tab-authority.test.ts new file mode 100644 index 00000000000..b1d80397864 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-authority.test.ts @@ -0,0 +1,538 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +import { beforeEach, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './rpc/methods/structured-agent-session-reveal' +import { + seedTestAgentSessionStoreFromNewerBuild, + editPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' +import { STRUCTURED_AGENT_SESSION_STATUS_METHODS } from './rpc/methods/structured-agent-session-status-stream' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { + root, + B, + C, + G, + AuthorityRuntime, + authorityProfile, + openAuthorityRuntime, + createC, + holdStartup, + physicalReload, + rejectVisibility, + replaceSaved, + savedIds +} from './structured-agent-session-tab-authority.test-support' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { chatTabCommandsSettled } from './session-layout-apply.test-fixture' +import * as journalFactory from '../native-chat/agent-session-journal/journal-store-factory' +import { discardStructuredWorkerSession } from './rpc/methods/orchestration-structured-worker-session' +import { closeStructuredAgentSessionChild } from './agent-session/structured-agent-session-close' + +// CLI capability probing is outside tab authority and must not start a real CLI in this fixture. +vi.mock('../claude/claude-cli-flag-prewarm', () => ({ + prewarmClaudeCliFlags: async () => undefined +})) + +const layoutOwner = vi.hoisted(() => ({ main: false })) +vi.mock('../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return layoutOwner.main + } +})) +beforeEach(() => { + layoutOwner.main = false +}) + +it.each([false, true])( + 'captures the initialized profile before mutable snapshot/lazy admission, firstMessage=%s', + async (first) => { + const profile = await authorityProfile([G, B]) + const runtime = new AuthorityRuntime(profile.profile) + replaceSaved(profile, []) + const rig = await openAuthorityRuntime(profile, runtime) + expect(runtime.capturedSelection()).toEqual([G, B]) + expect(rig.host.deps.store).toBe(rig.store) + expect(rig.host.getPersistedVisibleSessionTabIndex().present).toBe(false) + const prepare = vi.spyOn(rig.store.founding, 'prepare') + expect(await createC(rig, first)).toMatchObject({ ok: true }) + expect(prepare).toHaveBeenCalled() + expect(rig.host.getPersistedVisibleSessionTabIndex()).toEqual({ + present: true, + sessionIds: [B, C] + }) + const journal = vi.spyOn(journalFactory, 'openAgentSessionJournal') + await rig.runtime.restoreStructuredAgentSessionTabs() + await chatTabCommandsSettled() + expect(journal).not.toHaveBeenCalled() + expect(rig.host.listSessionTabs().map((row) => row.sessionId)).toEqual([B, C]) + expect(savedIds(rig)).toEqual(expect.arrayContaining([B, C])) + expect(rig.acquire).not.toHaveBeenCalled() + const fresh = await physicalReload(rig) + expect(fresh.profile).not.toBe(rig.profile) + expect(fresh.store).not.toBe(rig.store) + expect(fresh.host.deps.journalDatabase).not.toBe(rig.host.deps.journalDatabase) + expect(fresh.host.getPersistedVisibleSessionTabIndex().sessionIds).toEqual([B, C]) + } +) + +it.each([false, true])( + 'failed passive SQL and rejected hide do not spend the captured base, firstMessage=%s', + async (first) => { + const rig = await openAuthorityRuntime(await authorityProfile()) + const allow = rejectVisibility(rig) + const release = await holdStartup(rig) + expect(rig.host.getPersistedVisibleSessionTabIndex().present).toBe(false) + replaceSaved(rig, []) + await expect(rig.host.setSessionTabVisibility(B, false)).rejects.toThrow('visibility rejected') + expect(savedIds(rig)).toEqual([]) + expect(rig.store.isSessionTabVisible(B)).toBe(true) + await (first + ? expect(createC(rig, first)).resolves.toMatchObject({ ok: false }) + : expect(createC(rig, first)).rejects.toThrow('visibility rejected')) + expect(rig.store.getRecord(C)).toBeNull() + expect(rig.host.getPersistedVisibleSessionTabIndex().present).toBe(false) + allow() + expect(await createC(rig, first)).toMatchObject({ ok: true }) + expect(rig.host.getPersistedVisibleSessionTabIndex().sessionIds).toEqual([B, C]) + await release() + expect(savedIds(rig)).toEqual(expect.arrayContaining([B, C])) + expect((await physicalReload(rig)).store.listVisibleSessionIds()).toEqual([B, C]) + } +) + +it.each([false, true])( + 'refuses a held legacy identity before accepting C/message, firstMessage=%s', + async (first) => { + const rig = await openAuthorityRuntime(await authorityProfile()) + expect(await createC(rig, first, structuredAgentSessionTabId(B))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict', details: { reason: 'tabIdTaken' } } + }) + expect(rig.store.getRecord(C)).toBeNull() + expect(rig.host.getPersistedVisibleSessionTabIndex().present).toBe(false) + expect(rig.acquire).not.toHaveBeenCalled() + await rig.store.initializeSessionTabs() + expect(await createC(rig, first, structuredAgentSessionTabId(B))).toMatchObject({ + ok: false, + refusal: { details: { reason: 'tabIdTaken' } } + }) + expect(await createC(rig, first, 'free-custom-tab')).toMatchObject({ ok: true }) + expect(rig.store.getSessionTabId(C)).toBe('free-custom-tab') + expect(rig.store.getSessionTabId(B)).toBe(structuredAgentSessionTabId(B)) + } +) + +it.each([false, true])( + 'accepted hide wins over loaded and unopened late saved publication, loaded=%s', + async (loaded) => { + const rig = await openAuthorityRuntime(await authorityProfile()) + if (loaded) { + expect(await rig.host.revealSession(B)).toMatchObject({ readable: true }) + } + expect(rig.host.hasSession(B)).toBe(loaded) + rig.runtime.projectStructuredAgentSessionTab({ + workspaceId: rig.workspace, + sessionId: B, + agent: 'codex', + activate: false + }) + const release = await holdStartup(rig) + const publish = vi.spyOn(rig.host.deps.statusSink!, 'publish') + await rig.host.setSessionTabVisibility(B, false) + expect(rig.runtime.live(B)).toBe(false) + publish.mockClear() + await release() + expect(publish).not.toHaveBeenCalled() + expect(rig.host.listSessionTabs()).toEqual([]) + expect(rig.runtime.live(B)).toBe(false) + expect(savedIds(rig)).toEqual([]) + const fresh = await physicalReload(rig) + expect(fresh.store.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [] }) + expect(fresh.host.listSessionTabs()).toEqual([]) + } +) + +it('preserves a later explicit failed-show reopen and refreshes its snapshot-derived lookup', async () => { + const rig = await openAuthorityRuntime(await authorityProfile()) + const release = await holdStartup(rig) + await rig.host.setSessionTabVisibility(B, false) + const allow = rejectVisibility(rig) + await rig.runtime.publishStructuredAgentSessionTab({ + workspaceId: rig.workspace, + sessionId: B, + agent: 'codex', + activate: true + }) + expect(rig.store.isSessionTabVisible(B)).toBe(false) + expect(rig.runtime.live(B)).toBe(true) + await release() + expect(savedIds(rig)).toContain(B) + expect(rig.runtime.live(B)).toBe(true) + const snapshot = rig.runtime.mobileSessionTabsByWorktree.get(rig.workspace)! + rig.runtime.storeMobileSessionSnapshot(rig.workspace, { ...snapshot, tabs: [] }) + expect(rig.runtime.live(B)).toBe(false) + rig.runtime.storeMobileSessionSnapshot(rig.workspace, snapshot) + expect(rig.runtime.live(B)).toBe(true) + rig.runtime.mobileSessionTabsByWorktree.delete(rig.workspace) + expect(rig.runtime.live(B)).toBe(false) + allow() + await rig.host.setSessionTabVisibility(B, true) + await rig.host.setSessionTabVisibility(B, false) + await chatTabCommandsSettled() + expect(savedIds(rig)).toEqual([]) +}) + +it.each([false, true])( + 'retains exact known/null prior-ID rollback after unproven close, known=%s', + async (known) => { + const rig = await openAuthorityRuntime(await authorityProfile()) + if (known) { + await rig.host.setSessionTabVisibility(B, true, 'known-tab') + } + await rig.store.transitionHandoff(B, (record) => ({ + ...record, + lease: { ...record.lease, claimStatus: 'reserved', reservedSpawnToken: 'pending-owner' } + })) + vi.spyOn(rig.host, 'close').mockRejectedValueOnce(new Error('child close unproven')) + const result = await closeStructuredAgentSessionChild(B, { runtime: rig.runtime }) + expect(result).toMatchObject({ stopped: false, closeAttempted: true }) + expect(rig.store.getSessionTabId(B)).toBe(known ? 'known-tab' : null) + expect(rig.store.isSessionTabVisible(B)).toBe(known) + } +) + +it('failed worker discard explicitly restores a known tab instead of relying on a loaded handle', async () => { + const rig = await openAuthorityRuntime(await authorityProfile()) + await rig.host.setSessionTabVisibility(B, true, 'worker-tab') + await rig.store.transitionHandoff(B, (record) => ({ + ...record, + lease: { ...record.lease, claimStatus: 'reserved', reservedSpawnToken: 'pending-owner' } + })) + vi.spyOn(rig.host, 'close').mockRejectedValueOnce(new Error('child close unproven')) + await discardStructuredWorkerSession(B, rig.runtime) + expect(rig.store.getSessionTabId(B)).toBe('worker-tab') + expect(rig.host.listSessionTabs().map((row) => row.sessionId)).toEqual([B]) +}) + +it.each([undefined, [], [C]])( + 'honors absent versus present/empty/old-partial authority: %j', + async (visible) => { + const rig = await openAuthorityRuntime( + await authorityProfile([G, B], { visible, records: [B, C] }) + ) + const journal = vi.spyOn(journalFactory, 'openAgentSessionJournal') + const transition = vi.spyOn(rig.store, 'transitionHandoff') + await rig.runtime.restoreStructuredAgentSessionTabs() + await chatTabCommandsSettled() + expect(rig.store.listVisibleSessionIds()).toEqual(visible ?? [B]) + expect(savedIds(rig)).toEqual(visible ?? [B]) + expect(journal).not.toHaveBeenCalled() + expect(transition).not.toHaveBeenCalled() + } +) + +it('leaves absence when no selected record is representable; rechecks raw selection on a genuine later action', async () => { + const rig = await openAuthorityRuntime(await authorityProfile([G], { records: [] })) + await rig.runtime.restoreStructuredAgentSessionTabs() + expect(rig.store.getVisibleSessionTabIndex().present).toBe(false) + expect(savedIds(rig)).toEqual([G]) + expect(rig.runtime.capturedSelection()).toEqual([G]) + expect(await createC(rig, false)).toMatchObject({ ok: true }) + expect(rig.store.listVisibleSessionIds()).toEqual([C]) +}) + +it.each(['group', 'folder'] as const)( + 'keeps accepted %s removal and does not fabricate an owner from the captured base', + async (kind) => { + const rig = await openAuthorityRuntime(await authorityProfile()) + const allow = rejectVisibility(rig) + const release = await holdStartup(rig) + allow() + const removed = + kind === 'group' + ? await rig.runtime.deleteProjectGroup(rig.groupId) + : await rig.runtime.deleteFolderWorkspace(rig.folderId) + expect(removed).toMatchObject({ deleted: true }) + await release() + expect(rig.profile.getFolderWorkspaces()).toEqual([]) + expect(savedIds(rig)).toEqual([]) + expect(rig.store.getRecord(B)).not.toBeNull() + } +) + +it.each([false, true])( + 'rechecks each queued entry and follows an in-flight open with current close, reopened=%s', + async (reopened) => { + layoutOwner.main = true + const second = 'chat-second-0005' + const rig = await openAuthorityRuntime(await authorityProfile([B, second])) + const release = await holdStartup(rig) + replaceSaved(rig, []) + const entered = Promise.withResolvers(), + finish = Promise.withResolvers() + const apply = rig.runtime.applyLayoutCommand.bind(rig.runtime) + const issued: string[] = [] + vi.spyOn(rig.runtime, 'applyLayoutCommand').mockImplementation(async (command) => { + if (command.type === 'openAgentSessionTab') { + issued.push(command.sessionId) + if (command.sessionId === B) { + entered.resolve() + await finish.promise + } + } + return apply(command) + }) + const restore = release() + await entered.promise + await rig.host.setSessionTabVisibility(second, false) + await rig.host.setSessionTabVisibility(B, false) + if (reopened) { + await rig.runtime.publishStructuredAgentSessionTab({ + workspaceId: rig.workspace, + sessionId: B, + agent: 'codex', + activate: true + }) + } + finish.resolve() + await restore + await chatTabCommandsSettled() + expect(issued).toEqual([B]) + expect(savedIds(rig)).toEqual(reopened ? [B] : []) + expect(rig.runtime.live(B)).toBe(reopened) + expect(rig.runtime.live(second)).toBe(false) + expect((await physicalReload(rig)).store.listVisibleSessionIds()).toEqual(reopened ? [B] : []) + } +) + +it('first orcad status client uses the pre-snapshot capture through install-first and admission reuse', async () => { + const profile = await authorityProfile([G, B]) + const runtime = new AuthorityRuntime(profile.profile) + replaceSaved(profile, []) + const rig = await openAuthorityRuntime(profile, runtime, true) + expect(rig.host.deps.store).toBe(rig.store) + const journals = vi.spyOn(journalFactory, 'openAgentSessionJournal') + const emit = vi.fn() + const abort = new AbortController() + const dispatcher = new RpcDispatcher({ + runtime, + methods: STRUCTURED_AGENT_SESSION_STATUS_METHODS + }) + try { + await dispatcher.dispatchStreaming( + { + id: 'first-orcad', + authToken: 'token', + method: 'agentSession.subscribeStatus', + params: null + }, + (raw) => emit(JSON.parse(raw)), + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + signal: abort.signal + } + ) + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + type: 'snapshot', + sessions: [expect.objectContaining({ sessionId: B })] + }) + }) + ) + expect(rig.store.listVisibleSessionIds()).toEqual([B]) + expect(journals).not.toHaveBeenCalled() + expect(rig.acquire).not.toHaveBeenCalled() + } finally { + abort.abort() + } +}) + +it('checked unreadable selection survives but raw unreadable and foreign saved rows do not establish membership', async () => { + const raw = 'raw-unreadable-0006', + foreign = 'foreign-chat-0007' + const profile = await authorityProfile([G, B, raw, foreign]) + await editPersistedTestAgentSessionStore(root, (state) => { + Object.assign(state.records[B].lease, { minimumNextFence: 'bad' }) + Object.assign(state.records[raw], { providerHandleChain: 'bad' }) + }) + const saved = profile.profile.getWorkspaceSession('local') + profile.profile.setWorkspaceSession({ + ...saved, + unifiedTabs: Object.fromEntries( + Object.entries(saved.unifiedTabs ?? {}).map(([key, tabs]) => [ + key, + tabs.map((tab) => + tab.entityId === foreign + ? { ...tab, executionHostId: toSshExecutionHostId('foreign') } + : tab + ) + ]) + ) + }) + const rig = await openAuthorityRuntime(profile) + expect(rig.runtime.capturedSelection()).toEqual([G, B, raw]) + expect( + rig.profile + .getWorkspaceSession('local') + .unifiedTabs?.[rig.workspace]?.find((tab) => tab.entityId === foreign)?.executionHostId + ).toBe(toSshExecutionHostId('foreign')) + expect(rig.store.isSessionUnreadable(B)).toBe(true) + expect(rig.store.getConversation(B)).not.toBeNull() + expect(rig.store.getConversation(raw)).toBeNull() + await rig.runtime.restoreStructuredAgentSessionTabs() + expect(rig.store.listVisibleSessionIds()).toEqual([B]) + expect( + rig.profile + .getWorkspaceSession('local') + .unifiedTabs?.[rig.workspace]?.map((tab) => tab.entityId) + ).toEqual([B, raw, foreign]) + expect( + rig.profile + .getWorkspaceSession('local') + .unifiedTabs?.[rig.workspace]?.some((tab) => tab.entityId === foreign) + ).toBe(true) +}) + +it('refused switched predicates and metadata consume no base; an own custom show overrides only its derived ID', async () => { + const rig = await openAuthorityRuntime(await authorityProfile()) + expect(await rig.store.setSwitchedSessionTabVisibility(B, 'own-tab', true, () => false)).toBe( + false + ) + await rig.store.setConversationName(B, 'still selected') + expect(rig.store.getVisibleSessionTabIndex().present).toBe(false) + await rig.store.setSessionTabVisibility(B, true, 'own-tab') + expect(rig.store.getSessionTabId(B)).toBe('own-tab') + expect(rig.store.listVisibleSessionIds()).toEqual([B]) +}) + +it('late reserved record is rechecked at the actual first visibility draft without reservation materialization', async () => { + const rig = await openAuthorityRuntime(await authorityProfile([B], { records: [] })) + await rig.runtime.restoreStructuredAgentSessionTabs() + expect(rig.store.getVisibleSessionTabIndex().present).toBe(false) + const now = 1_700_000_000_000 + await rig.store.reserveOwner({ + sessionId: B, + provider: 'codex', + location: { + executionHostId: 'local', + workspaceId: rig.workspace, + workspaceKind: 'folder', + wslDistro: null + }, + accountHome: { variable: 'CODEX_HOME', path: root }, + expectedFence: null, + spawnToken: 'late-reservation', + claimKeyId: 'late-key', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'late', + operationId: `${now}-00000000000000000000000000000001`, + fingerprint: 'late-record' + }, + now + }) + expect(rig.store.getRecord(B)).not.toBeNull() + expect(rig.store.getVisibleSessionTabIndex().present).toBe(false) + expect(await createC(rig, false)).toMatchObject({ ok: true }) + expect(rig.store.listVisibleSessionIds()).toEqual([B, C]) +}) + +it.each([false, true])( + 'SQL COMMIT rejection adopts no authority or record, firstMessage=%s', + async (first) => { + const rig = await openAuthorityRuntime(await authorityProfile()) + const db = rig.host.deps.journalDatabase.db + const exec = db.exec.bind(db) + const failure = vi.spyOn(db, 'exec').mockImplementation((sql) => { + if ( + sql === 'COMMIT' && + db + .prepare("SELECT value FROM agent_session_store_meta WHERE key='session_tabs_recorded'") + .get() + ) { + throw new Error('commit rejected') + } + return exec(sql) + }) + await (first + ? expect(createC(rig, first)).resolves.toMatchObject({ ok: false }) + : expect(createC(rig, first)).rejects.toThrow('commit rejected')) + expect(rig.store.getVisibleSessionTabIndex().present).toBe(false) + expect(rig.store.getRecord(C)).toBeNull() + failure.mockRestore() + expect(await createC(rig, first)).toMatchObject({ ok: true }) + expect(rig.store.listVisibleSessionIds()).toEqual([B, C]) + } +) + +it('accepted hide retires every old local runtime row while preserving a foreign mirror', async () => { + const rig = await openAuthorityRuntime(await authorityProfile()) + await rig.host.setSessionTabVisibility(B, true) + rig.runtime.projectStructuredAgentSessionTab({ + workspaceId: rig.workspace, + sessionId: B, + agent: 'codex', + activate: true + }) + const original = rig.runtime.mobileSessionTabsByWorktree.get(rig.workspace)! + const duplicate = rig.profile.createFolderWorkspace({ + projectGroupId: rig.groupId, + folderPath: `${root}/duplicate`, + name: 'Duplicate' + }) + const mirror = rig.profile.createFolderWorkspace({ + projectGroupId: rig.groupId, + folderPath: `${root}/mirror`, + name: 'Mirror', + connectionId: 'foreign' + }) + const duplicateKey = folderWorkspaceKey(duplicate.id), + mirrorKey = folderWorkspaceKey(mirror.id) + rig.runtime.storeMobileSessionSnapshot(duplicateKey, { ...original, worktree: duplicateKey }) + const foreign = rig.runtime.storeMobileSessionSnapshot(mirrorKey, { + ...original, + worktree: mirrorKey + }) + await rig.host.setSessionTabVisibility(B, false) + expect(rig.runtime.mobileSessionTabsByWorktree.get(rig.workspace)?.tabs).toEqual([]) + expect(rig.runtime.mobileSessionTabsByWorktree.get(duplicateKey)?.tabs).toEqual([]) + expect(rig.runtime.mobileSessionTabsByWorktree.get(mirrorKey)).toBe(foreign) + expect(rig.runtime.retireStructuredAgentSessionTabFromSnapshot(B)).toBe(false) + expect(rig.runtime.mobileSessionTabsByWorktree.get(mirrorKey)).toBe(foreign) +}) + +it('read-only ordinary reveal remains live through held startup without promising new durable visibility', async () => { + const profile = await authorityProfile([B], { visible: [] }) + await seedTestAgentSessionStoreFromNewerBuild(root) + const rig = await openAuthorityRuntime(profile) + expect(rig.store.readOnly).toBe(true) + const release = await holdStartup(rig) + const dispatcher = new RpcDispatcher({ + runtime: rig.runtime, + methods: STRUCTURED_AGENT_SESSION_REVEAL_METHODS + }) + expect( + await dispatcher.dispatch( + { + id: 'read-only-reveal', + authToken: 'token', + method: 'agentSession.reveal', + params: { sessionId: B } + }, + { clientKind: 'runtime', clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } + ) + ).toMatchObject({ ok: true, result: { ok: true, readable: false } }) + expect(rig.runtime.live(B)).toBe(true) + await release() + expect(rig.store.listVisibleSessionIds()).toEqual([]) + expect(savedIds(rig)).toEqual([B]) + expect(rig.runtime.live(B)).toBe(true) + expect(rig.acquire).not.toHaveBeenCalled() +}) diff --git a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts index 490faff82eb..9dfbfb30fad 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts @@ -98,23 +98,31 @@ async function runtimeWithSavedChats( (next, hostId) => store.setWorkspaceSession(next, hostId) ) } - const runtime = new OrcaRuntimeService(partialRuntimeStore(store)) - for (const sessionId of sessionIds) { - await runtime.publishStructuredAgentSessionTab({ - workspaceId: WT, - sessionId, - agent: 'codex', - activate: false - }) - } + state.session = + withChatTabChanges( + 'local', + state.session, + WT, + sessionIds.map((sessionId) => ({ + open: { sessionId, agent: 'codex', tabId: structuredAgentSessionTabId(sessionId) } + })) + ) ?? state.session + const runtime = new WindowlessRuntime(partialRuntimeStore(store)) await settled() return { runtime, state } } -function installHost(runtime: OrcaRuntimeService) { +class WindowlessRuntime extends OrcaRuntimeService { + capturedSelection() { + return this.legacyStructuredSessionTabIds + } +} + +function installHost(runtime: WindowlessRuntime) { return ensureStructuredAgentSessionHost({ logger: recordingStructuredAgentSessionLogger().logger, stateDirectory: root, + legacySessionTabIds: runtime.capturedSelection(), hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, @@ -126,7 +134,7 @@ function installHost(runtime: OrcaRuntimeService) { } /** A runtime whose startup restore installs a host rooted at `root`, with no PTY daemon. */ -function restoringRuntime(runtime: OrcaRuntimeService): void { +function restoringRuntime(runtime: WindowlessRuntime): void { // 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 @@ -275,7 +283,7 @@ describe('a windowless chat tab against the chat host', () => { ]) }) - it('prunes nothing at startup when the store has no tab index', async () => { + it('materializes the complete representable legacy base before missing-record cleanup', async () => { await seedTestAgentSessionRecordStore(root, { records: [released(CHAT_B)] }) const { runtime, state } = await runtimeWithSavedChats([CHAT_GONE, CHAT_B]) restoringRuntime(runtime) @@ -283,6 +291,10 @@ describe('a windowless chat tab against the chat host', () => { await runtime.restoreStructuredAgentSessionTabs() await settled() - expect(savedChatIds(state)).toEqual([CHAT_GONE, CHAT_B]) + expect(savedChatIds(state)).toEqual([CHAT_B]) + expect((await installHost(runtime)).getPersistedVisibleSessionTabIndex()).toEqual({ + present: true, + sessionIds: [CHAT_B] + }) }) }) diff --git a/src/main/runtime/structured-agent-session-windowless-layout.test.ts b/src/main/runtime/structured-agent-session-windowless-layout.test.ts index 496b92c7571..d5bcf4b932e 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../../shared/constants' import { normalizeExecutionHostId } from '../../shared/execution-host' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from './orca-runtime' import { withChatTabChanges } from './agent-session/agent-session-tab-layout' @@ -48,9 +49,17 @@ function runtimeStore(state: { session: WorkspaceSessionState }) { const settled = chatTabCommandsSettled -function useChatHost(fake: Record): void { +function useChatHost(fake: Partial): void { + const index = () => + typeof fake.getPersistedVisibleSessionTabIndex === 'function' + ? fake.getPersistedVisibleSessionTabIndex() + : { present: false, sessionIds: [] } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each test supplies every host member its path reads. - setStructuredAgentSessionHost(fake as never) + setStructuredAgentSessionHost({ + deps: { store: { getConversation: () => null, isSessionUnreadable: () => false } }, + isSessionTabVisible: (id: string) => index().sessionIds.includes(id), + ...fake + } as never) } function chat(sessionId: string) { @@ -239,7 +248,7 @@ describe('a chat tab on a host with no window', () => { useChatHost({ reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['chat-1'] }), - restoreReadableSessions: async () => undefined, + seedSessionTabs: async () => undefined, listSessionTabs: () => [{ sessionId: 'chat-1', workspaceId: WT, agent: 'codex' }] }) diff --git a/src/main/runtime/structured-worker-custody.ts b/src/main/runtime/structured-worker-custody.ts index df793c26a83..62eead60db8 100644 --- a/src/main/runtime/structured-worker-custody.ts +++ b/src/main/runtime/structured-worker-custody.ts @@ -38,6 +38,9 @@ export function structuredSessionTabRetired( sessionId: string ): boolean { try { + if (typeof host.deps?.store?.isSessionTabRetired === 'function') { + return host.deps.store.isSessionTabRetired(sessionId) + } const index = host.getPersistedVisibleSessionTabIndex?.() return index?.present === true && !index.sessionIds.includes(sessionId) } catch { diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 85f239a635d..bfd51e7c85d 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -204,6 +204,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr async openAgentSessionRecordStore(): Promise { const { store } = await openAgentSessionRecordStoreOnce({ stateDirectory: getProfileUserDataPath(), + legacySessionTabIds: this.legacyStructuredSessionTabIds, hostId: LOCAL_EXECUTION_HOST_ID, logger: neverThrowingStructuredAgentSessionLogger(createStructuredAgentSessionLogger()) }) @@ -234,6 +235,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr ) await installStructuredAgentSessionHost({ stateDirectory: getProfileUserDataPath(), + legacySessionTabIds: this.legacyStructuredSessionTabIds, hostId: LOCAL_EXECUTION_HOST_ID, claimKeyId: this.agentSessionClaimSigner.keyId, logger, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx index ee3975f8d80..02a446c6c4b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx @@ -13,6 +13,9 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' import type { AppState } from '@/store/types' import { countActivityUnread } from '../activity/useActivityUnreadCount' +import { savedStatusContracts } from '../../../../shared/agent-session-saved-status.test-fixture' +import { resolveAttention } from '../../../../shared/sidebar-list/smart-attention' +import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' type TestStore = { getState: () => AppState @@ -125,6 +128,27 @@ describe("the structured row's state clock", () => { resetStructuredAgentSessionStatusFeedsForTests() }) + it.each(savedStatusContracts)( + 'projects saved $outcome through desktop status and Smart Sort on the original turn clock', + async ({ summary: savedSummary, outcome, attentionClass: cls, text }) => { + const emit = await connect() + emit({ type: 'snapshot', sessions: [savedSummary] }) + expect(row()).toMatchObject({ + state: 'done', + stateStartedAt: SETTLED, + interrupted: outcome === 'cancellation', + mainAgent: { state: 'done', outcome, stateStartedAt: SETTLED } + }) + expect(agentVerdictStatusLine(row())).toBe(text) + expect( + resolveAttention([{ kind: 'hook', entry: row(), hasLivePty: false }], SETTLED + 1) + ).toEqual({ + cls, + attentionTimestamp: cls === 2 ? SETTLED : 0 + }) + } + ) + it('keeps a settled parent read while its subagent writes, until its own next turn ends', async () => { const emit = await connect() emit({ type: 'snapshot', sessions: [summary({ statusStartedAt: SETTLED })] }) diff --git a/src/shared/agent-session-last-turn.ts b/src/shared/agent-session-last-turn.ts new file mode 100644 index 00000000000..c0107e4d0d5 --- /dev/null +++ b/src/shared/agent-session-last-turn.ts @@ -0,0 +1,36 @@ +import { isAgentTurnOutcome, type AgentTurnOutcome } from './agent-turn-outcome' + +export type AgentSessionLastTurn = { + state: 'open' | 'settled' + outcome?: AgentTurnOutcome + at: number + prompt?: string + reply?: string +} + +export const AGENT_SESSION_TURN_PREVIEW_CHARS = 280 + +/** Optional metadata must never make the conversation itself unreadable. */ +export function isAgentSessionLastTurn(value: unknown): value is AgentSessionLastTurn { + if (typeof value !== 'object' || value === null) { + return false + } + return ( + 'state' in value && + (value.state === 'open' || value.state === 'settled') && + 'at' in value && + typeof value.at === 'number' && + Number.isSafeInteger(value.at) && + value.at >= 0 && + (!('outcome' in value) || value.outcome === undefined || isAgentTurnOutcome(value.outcome)) && + (!('prompt' in value) || isPreview(value.prompt)) && + (!('reply' in value) || isPreview(value.reply)) + ) +} + +function isPreview(value: unknown): boolean { + return ( + value === undefined || + (typeof value === 'string' && value.length <= AGENT_SESSION_TURN_PREVIEW_CHARS) + ) +} diff --git a/src/shared/agent-session-legacy-handoff-lease.ts b/src/shared/agent-session-legacy-handoff-lease.ts index 6477f5abe1a..ea2951a236e 100644 --- a/src/shared/agent-session-legacy-handoff-lease.ts +++ b/src/shared/agent-session-legacy-handoff-lease.ts @@ -25,10 +25,11 @@ export type PersistedAgentSessionLease = Omit & { lease: PersistedAgentSessionLease sleptAt?: unknown + lastTurn?: unknown providerHandleChain: PersistedAgentSessionProviderHandleLink[] } diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index ffef0512060..1372c8ae468 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -421,3 +421,29 @@ describe('a chat whose saved conversation could not be restored', () => { expect(decodePersistedAgentSessionProviderHandleChain(stored)).toBeNull() }) }) + +it.each([ + null, + 'open', + {}, + { state: 'open', at: -1 }, + { state: 'settled', at: 10, outcome: 'newer' }, + { state: 'open', at: 10, prompt: 'p'.repeat(281) } +])('ignores malformed optional last-turn metadata: %j', (lastTurn) => { + const stored = { ...encodeAgentSessionRecord(agentSessionRecordFixture()), lastTurn } + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + expect(decodePersistedAgentSessionRecord(stored).record.lastTurn).toBeUndefined() +}) + +it('preserves the saved turn and unknown JSON through a record transition', () => { + const stored = { + ...encodeAgentSessionRecord(agentSessionRecordFixture()), + lastTurn: { state: 'settled', outcome: 'success', at: 10 }, + laterField: 'keep' + } + const record = decodePersistedAgentSessionRecord(stored).record + expect(encodeAgentSessionRecord({ ...record, updatedAt: 20 })).toMatchObject({ + lastTurn: stored.lastTurn, + laterField: 'keep' + }) +}) diff --git a/src/shared/agent-session-record-stored-form.ts b/src/shared/agent-session-record-stored-form.ts index 14c7aa347f3..b2f8774e9af 100644 --- a/src/shared/agent-session-record-stored-form.ts +++ b/src/shared/agent-session-record-stored-form.ts @@ -10,6 +10,7 @@ import { decodePersistedAgentSessionProviderHandleChain, encodePersistedAgentSessionProviderHandleChain } from './agent-session-provider-handle' +import { isAgentSessionLastTurn } from './agent-session-last-turn' import type { AgentSessionRecord } from './agent-session-record' /** Report decode changes needing writeback; handle encoding alone is unchanged across builds and needs none. */ @@ -22,12 +23,13 @@ export function decodePersistedAgentSessionRecord 0 return { record: { ...rest, ...(validSleep ? { sleptAt } : {}), + ...(isAgentSessionLastTurn(lastTurn) ? { lastTurn } : {}), providerHandleChain, lease: normalizeLegacyHandoffLease(record.lease) }, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index d728e9a18d5..2943edb249e 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -10,6 +10,7 @@ import { } from './agent-session-legacy-handoff-lease' /** Identity pins location, provider and account home independently of terminal tabs; the lease separately grants one process writer authority. */ +import type { AgentSessionLastTurn } from './agent-session-last-turn' import type { ExecutionHostId } from './execution-host' import { isAgentSessionConversationCommandRecord, @@ -140,6 +141,8 @@ export type AgentSessionRecord = { conversationNameSource?: 'user' /** When the user's Sleep stopped this chat; the next admitted send clears it. */ sleptAt?: number + /** Latest turn edge for lists that must not open conversation history. */ + lastTurn?: AgentSessionLastTurn launchArgs?: AgentSessionLaunchArgs lease: AgentSessionLease createdAt: number diff --git a/src/shared/agent-session-saved-status.test-fixture.ts b/src/shared/agent-session-saved-status.test-fixture.ts new file mode 100644 index 00000000000..2b0dcd2a9e5 --- /dev/null +++ b/src/shared/agent-session-saved-status.test-fixture.ts @@ -0,0 +1,58 @@ +import type { AgentSessionLastTurn } from './agent-session-last-turn' +import type { AgentSessionRecord } from './agent-session-record' +import type { AgentSessionStatusSummary } from './agent-session-wire' +import type { AgentTurnOutcome } from './agent-turn-outcome' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from './agent-session-record.test-fixture' + +export const SAVED_STATUS_AT = 22_000 + +export const savedStatusContracts = ( + [ + [{ state: 'settled', outcome: 'success', at: SAVED_STATUS_AT }, 'success', 2, null], + [{ state: 'open', at: SAVED_STATUS_AT }, 'cancellation', 5, 'Interrupted by user'], + [ + { state: 'settled', outcome: 'cancellation', at: SAVED_STATUS_AT }, + 'cancellation', + 5, + 'Interrupted by user' + ], + [{ state: 'settled', outcome: 'failure', at: SAVED_STATUS_AT }, 'failure', 2, 'Failed'], + [ + { state: 'settled', outcome: 'interruption', at: SAVED_STATUS_AT }, + 'interruption', + 2, + 'Failed' + ] + ] satisfies [AgentSessionLastTurn, AgentTurnOutcome, number, string | null][] +).map(([lastTurn, outcome, attentionClass, text]) => ({ + record: { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-1' })), + sessionId: 'session-1', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'wt-1', + workspaceKind: 'git-worktree' + }, + updatedAt: SAVED_STATUS_AT + 60_000, + lastTurn: { ...lastTurn, prompt: 'saved prompt', reply: 'saved reply' } + } satisfies AgentSessionRecord, + summary: { + sessionId: 'session-1', + workspaceId: 'wt-1', + agent: 'claude', + status: 'idle', + latestPrompt: 'saved prompt', + lastAssistantMessage: 'saved reply', + runtimePermission: null, + turnOutcome: outcome, + statusStartedAt: SAVED_STATUS_AT, + updatedAt: SAVED_STATUS_AT + } satisfies AgentSessionStatusSummary, + outcome, + attentionClass, + text +})) diff --git a/src/shared/native-chat/native-chat-orca-stop-cut.ts b/src/shared/native-chat/native-chat-orca-stop-cut.ts index 6140f91abfd..641a9f28d0c 100644 --- a/src/shared/native-chat/native-chat-orca-stop-cut.ts +++ b/src/shared/native-chat/native-chat-orca-stop-cut.ts @@ -10,7 +10,10 @@ import { STALE_SESSION_ROW_PREFIX } from '../agent-session-stop-row-identity' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from '../agent-session-turn-record' import { agentTurnVerdict, type AgentTurnOutcome } from '../agent-turn-outcome' import { structuredAgentSessionCommandTurnItemIds } from '../structured-agent-session-command-entry' -import { isStructuredAgentSessionNonRequestRow } from '../structured-agent-session-latest-request' +import { + isStructuredAgentSessionNonRequestRow, + type StructuredAgentSessionLatestRequest +} from '../structured-agent-session-latest-request' // Dedupe shutdown rows by stopped child; preserve the legacy prefix so older clients add no duplicate notice. export function orcaShutdownRowClientMessageId( @@ -90,3 +93,16 @@ export function latestNativeChatOrcaStopCut( const cause = findInNativeChatTurn(items, turnItemId, orcaStopCauseOfRow) return cause ? { turnItemId, cause } : null } + +/** Intentional shutdown is cancellation in status; retain raw cut verdicts for Continue. */ +export function nativeChatStatusTurnOutcome( + request: StructuredAgentSessionLatestRequest | null, + items: readonly AgentJournalRenderItem[], + submissions: readonly Pick[] +): AgentTurnOutcome | null { + const verdict = request + ? agentTurnVerdict({ state: request.turnState, outcome: request.outcome }) + : null + const stop = verdict === 'interruption' ? latestNativeChatOrcaStopCut(items, submissions) : null + return stop && stop.cause !== 'crash' ? 'cancellation' : verdict +} diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 2727dbd3878..c9910b49704 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -90,7 +90,7 @@ export function latestStructuredAgentSessionRequest( id: item.itemId, turnState: null, outcome: 'failure', - settledAt: submission.resolvedAt ?? undefined + settledAt: submission.resolvedAt ?? submission.submittedAt } } } @@ -135,12 +135,12 @@ function rejectedSubmissionsByItem( return rejected } -/** A turn recovery settled ended when that settle was written: when the user learns it stopped. */ +/** Recovery dates its own settle; older provider ends without clocks use their turn publication. */ function turnEndedAt( item: AgentJournalRenderItem, turn: AgentJournalTurnLifecycle ): number | undefined { - return item.recoveredAt ?? turn.completedAt + return item.recoveredAt ?? turn.completedAt ?? item.observedAt } function messageProse(blocks: readonly NativeChatBlock[]): string { diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 2350626d1b7..26c5f027b39 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -11,7 +11,7 @@ import { type AgentJournalRenderItem, type AgentJournalSubmission } from './agent-session-journal-types' -import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome' +import type { AgentTurnOutcome } from './agent-turn-outcome' import { agentJournalToolCallLifecycle } from './agent-journal-tool-call-lifecycle' import { agentJournalLinkageFields } from './agent-session-journal-producer' import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' @@ -41,6 +41,7 @@ import { structuredAgentSessionStatusStartedAt } from './structured-agent-sessio import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work' import type { StructuredAgentSessionWorkScope } from './structured-agent-session-main-agent-working' import { agentSessionCurrentContextRows } from './agent-session-context-clear' +import { nativeChatStatusTurnOutcome } from './native-chat/native-chat-orca-stop-cut' // Keep existing live-turn/unanswered-send consumers on one import path. export { @@ -304,9 +305,7 @@ export function projectStructuredAgentSessionStatusState( const latestRequest = latestStructuredAgentSessionRequest(items, submissions) // Only idle sessions report a finished-request verdict. const request = status === 'idle' ? latestRequest : null - const turnOutcome = request - ? agentTurnVerdict({ state: request.turnState, outcome: request.outcome }) - : null + const turnOutcome = nativeChatStatusTurnOutcome(request, items, submissions) const statusStartedAt = structuredAgentSessionStatusStartedAt( status, items, diff --git a/src/shared/structured-agent-session-status-started-at.test.ts b/src/shared/structured-agent-session-status-started-at.test.ts index 85a9d475ea8..4f1a7592281 100644 --- a/src/shared/structured-agent-session-status-started-at.test.ts +++ b/src/shared/structured-agent-session-status-started-at.test.ts @@ -99,9 +99,9 @@ describe('when the session entered its status', () => { expect(summaryOf([ask, unverifiable]).statusStartedAt).toBe(7_000) }) - it('leaves an idle state undated when the journal records no end for it', () => { + it('dates an older turn end by its own publication, leaving message-only history undated', () => { const undated = item('turn', 2, { kind: 'turn', turnId: 't1', state: 'unverifiable' }) - expect(summaryOf([ask, undated])).not.toHaveProperty('statusStartedAt') + expect(summaryOf([ask, undated]).statusStartedAt).toBe(undated.observedAt) expect(summaryOf([ask])).not.toHaveProperty('statusStartedAt') }) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 653c38ce833..e82201a2031 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -800,9 +800,9 @@ describe('cross-version structured agent sessions', () => { const held = first.cursor const restarted = await bootHost('b') - await restarted.restoreReadableSessions() - // Restart restores the session for READING. The chat the client still has open takes its - // hold, and that is what gives the session a provider child again. + await restarted.seedSessionTabs([SESSION]) + await restarted.revealSession(SESSION) + // Demand-open restores reading; the retained chat's hold starts its provider child again. await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 8e07fbbc604..aa11841edcb 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -157,11 +157,12 @@ describe('a chat at rest keeps its worktree activatable', () => { expect(await activate()).toBe('structured') }) - it('after an app restart restored it for reading', async () => { + it('after an app restart opens it for reading', async () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) openHost() - await host.restoreReadableSessions() + await host.seedSessionTabs([SESSION]) + await host.revealSession(SESSION) expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', diff --git a/tests/e2e/structured-permission-report-publication.unit.test.ts b/tests/e2e/structured-permission-report-publication.unit.test.ts index ce5946dc4ee..8458b6dd42d 100644 --- a/tests/e2e/structured-permission-report-publication.unit.test.ts +++ b/tests/e2e/structured-permission-report-publication.unit.test.ts @@ -556,7 +556,10 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta }) await waitFor(() => expect(descriptions()).toEqual(['Ask for approval', 'Ask for approval'])) expect((await readCurrentOptions()).current.reportedPermissionMode).toBe('ask') - expect(store.getRecord(sessionId)).toEqual(saved) + await host.drainStatusWrites() + const updated = store.getRecord(sessionId) + expect(updated).toEqual({ ...saved, lastTurn: updated?.lastTurn }) + expect(updated?.lastTurn).toEqual({ state: 'open', at: expect.any(Number), prompt: '' }) expect((await host.readOptions(sessionId)).current).toMatchObject({ permissionMode: 'ask', reportedPermissionMode: 'ask' diff --git a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts index db57fe69ade..9bba78d50ec 100644 --- a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts +++ b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts @@ -1,3 +1,7 @@ +import type { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { partialRuntimeStore } from '../../src/main/runtime/runtime-store-partial-fixture' +import { agentSessionRecordFixture } from '../../src/main/native-chat/agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../src/shared/agent-session-conversation-view' // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' @@ -44,6 +48,25 @@ type ProcessBoundary = { pairedClose: Promise | null } +type RestoreCloseHost = Pick< + StructuredAgentSessionHost, + | 'reconcileRestartLeases' + | 'seedSessionTabs' + | 'getPersistedVisibleSessionTabIndex' + | 'isSessionTabVisible' + | 'close' + | 'setSessionTabVisibility' + | 'listSessionTabs' + | 'getSessionTabId' +> & { + deps: { + store: Pick< + StructuredAgentSessionHost['deps']['store'], + 'getConversation' | 'isSessionUnreadable' | 'isSessionTabRetired' + > + } +} + const bridge = vi.hoisted((): ProcessBoundary => ({ scenario: 'reopened', runtime: null, @@ -164,7 +187,7 @@ describe('reopened structured session close', () => { const hostSessionCloses: string[] = [] const closeResponses: (string | undefined)[] = [] - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) bridge.runtime = runtime let nextRequest = 0 runtime.setNotifier( @@ -184,7 +207,7 @@ describe('reopened structured session close', () => { listeners.focusEditorTab!({ tabId, worktreeId }) } as never ) - // No durable store, PTYs or workspace session on disk: the host tab list below is the whole state. + // The adapter owns checked records and current visibility; PTY/profile restoration is excluded. Object.assign(runtime, { hasPersistedStructuredAgentSessionStore: () => true, getKnownWorkspaceSessionWorktreeIds: () => new Set(), @@ -192,23 +215,45 @@ describe('reopened structured session close', () => { refreshMobileSessionPtyRecords: async () => new Set(), ensureStructuredAgentSessionHost: async () => undefined }) + const hostTabs = new Set([CURRENT_SESSION, REOPENED_SESSION]) + const records = new Map( + [...hostTabs].map((sessionId) => [ + sessionId, + agentSessionConversationView( + agentSessionRecordFixture({ sessionId, location: { workspaceId: WORKTREE } }) + ) + ]) + ) setStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and tab close call only these host members. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adapter supplies current selection/records and the restore/close API used here. { + deps: { + store: { + getConversation: (id: string) => records.get(id) ?? null, + isSessionUnreadable: () => false, + isSessionTabRetired: (id: string) => !hostTabs.has(id) + } + }, reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + seedSessionTabs: async () => undefined, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...hostTabs] }), + isSessionTabVisible: (id: string) => hostTabs.has(id) && records.has(id), close: async (sessionId: string) => { hostSessionCloses.push(sessionId) }, - setSessionTabVisibility: async () => undefined, + setSessionTabVisibility: async (id: string, visible: boolean) => { + if (visible) { + hostTabs.add(id) + } else { + hostTabs.delete(id) + } + }, // Index the reopened chat separately because the current chat retains its first session’s tab id. getSessionTabId: (sessionId: string) => sessionId === CURRENT_SESSION ? CURRENT_TAB : REOPENED_TAB, - listSessionTabs: () => [ - { sessionId: CURRENT_SESSION, workspaceId: WORKTREE, agent: 'claude' }, - { sessionId: REOPENED_SESSION, workspaceId: WORKTREE, agent: 'claude' } - ] - } as never + listSessionTabs: () => + [...hostTabs].map((sessionId) => ({ sessionId, workspaceId: WORKTREE, agent: 'claude' })) + } satisfies RestoreCloseHost as never ) // The current chat after /clear: first session's tab id, new session's entity. useAppStore.setState({ activeWorktreeId: WORKTREE }) @@ -321,7 +366,7 @@ describe('in-flight structured session close', () => { const pendingResponses = new Map void>() // A failing close answers when the test says, so its frame can be read first. const failingCloses: (() => void)[] = [] - const runtime = new OrcaRuntimeService() + const runtime = new OrcaRuntimeService(partialRuntimeStore()) bridge.runtime = runtime vi.stubGlobal('api', { ui: { @@ -355,7 +400,7 @@ describe('in-flight structured session close', () => { }) } as never ) - // No durable store, PTYs or workspace session on disk: the host tab list below is the whole state. + // The adapter owns checked records and current visibility; PTY/profile restoration is excluded. Object.assign(runtime, { hasPersistedStructuredAgentSessionStore: () => true, getKnownWorkspaceSessionWorktreeIds: () => new Set(), @@ -365,22 +410,42 @@ describe('in-flight structured session close', () => { }) const hostSessionCloses: string[] = [] const hostTabs = new Set(SESSIONS) + const records = new Map( + [...hostTabs].map((sessionId) => [ + sessionId, + agentSessionConversationView( + agentSessionRecordFixture({ sessionId, location: { workspaceId: WORKTREE } }) + ) + ]) + ) setStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and tab close call only these host members. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adapter supplies current selection/records and the restore/close API used here. { + deps: { + store: { + getConversation: (id: string) => records.get(id) ?? null, + isSessionUnreadable: () => false, + isSessionTabRetired: (id: string) => !hostTabs.has(id) + } + }, reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + seedSessionTabs: async () => undefined, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...hostTabs] }), + isSessionTabVisible: (id: string) => hostTabs.has(id) && records.has(id), close: async (sessionId: string) => { hostSessionCloses.push(sessionId) }, setSessionTabVisibility: async (sessionId: string, visible: boolean) => { - if (!visible) { + if (visible) { + hostTabs.add(sessionId) + } else { hostTabs.delete(sessionId) } }, + getSessionTabId: (id: string) => (hostTabs.has(id) ? tabIdOf(id) : null), listSessionTabs: () => [...hostTabs].map((sessionId) => ({ sessionId, workspaceId: WORKTREE, agent: 'claude' })) - } as never + } satisfies RestoreCloseHost as never ) useAppStore.setState({ activeWorktreeId: WORKTREE }) await mainHoldsWindowLayout(runtime)