From 641e4792b966960d65bd82a3744fe087a5ac05cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:20:52 -0700 Subject: [PATCH] Startup shows chats as Interrupted or Done without reading their history (#32146) * feat(agent-session-journal): save each chat's open root turn beside its epoch A restart could only tell whether a chat was cut mid-turn by replaying its whole journal. journal_sessions now keeps the open root turn (open_turn_id, open_turn_since), set by the row that opens a turn and cleared only by a finish the provider reported, in that row's own transaction. A turn a quit, crash or Stop cut short stays open, which is what the next launch reads as Interrupted. Journal schema version 5 adds the two nullable columns with a forward migration; a new epoch starts with no turn open. * fix(native-chat): restore chat tabs at startup without replaying any history Startup opened every chat in the saved tab index and replayed its whole journal into the main process to learn its status. With hundreds of tabs and gigabytes of history, Orca froze in GC on every launch. Startup now lists each restored chat from its record and publishes its row as Interrupted (a turn was open when its host stopped, or the last quit's resume offer lists it) or Done, through the status stream and the agent-status sink, reading only each chat's journal_sessions row and its newest row's time. A chat's history is read when something opens it; that open settles what the gone generation left running and publishes the journal's own row over the restored one. No other startup path replays chats: restart offers list from their markers without opening chats, and orchestration mail opens the chats it must read one at a time. Opened chats are capped at 24 resident folds; opening one past the cap closes the least recently active folds that are only a cache, with the idle sweep's own close test. * Fix lazy chat restoration and cache ownership * Restore saved chat work across runtime restarts * Wait for committed structural progress in migration fixture * Correct restored ownership and bounded journal recovery * Preserve bounded work selection during legacy recovery * Complete deferred restart status publication * Fix restart recovery test fixtures * Use legal legacy recovery fixtures * Correct population delete observation * Bound legacy journal replay in a readonly worker * Repair legacy worker proof oracles * Reduce legacy scratch key serialization cost * Decode population meter namespaces with scratch UTF-16 encoding * Consolidate structured session imports for CI * Remove redundant casts and document checked type exceptions * Correct compiler contracts and retain journal fixture invariants * Fix checked test fixture contracts after compiler validation * Name population fixture identity type in exported signature * Preserve historical quit presentation after rewind * Fix structured session retry and remote worker delivery * Preserve settlement and restored status contracts * Enable legacy chat readiness on standalone hosts * Repair structured session static checks * Verify retained submission identity across rewinds * Show saved chat status without replaying history at startup * Keep chats closed when startup status finishes loading * Keep startup owner recovery independent of history loading * Preserve intentional quit verdicts in saved chat status * Use a checked host fixture in startup restoration tests * Group saved startup roster publication and verify scoped status work * Use checked array access in status cut counters * Complete checked saved status fixture typing * Declare portable roster counter contract * Use current saved chat startup APIs in contracts * Preserve complete chat tab authority across startup * Repair checked startup restoration test consumers * Keep authority fixture module mocks in test entries * Consolidate authority test fixture imports * Check restore fixture contracts and recorded queue recovery --------- Co-authored-by: m4air Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - mobile/src/worktree/agent-row-display.test.ts | 10 + .../agent-hooks/agent-hook-runtime-deps.ts | 1 + .../server/server-ingest-structured.ts | 52 +- .../agent-hooks/server/server-listeners.ts | 2 + .../journal-host-database.spec.ts | 10 +- ...ssion-claude-stop-exit-ends-record.test.ts | 10 +- ...uctured-agent-session-clear-rewind.test.ts | 16 +- ...tructured-agent-session-client-delivery.ts | 18 +- ...ctured-agent-session-close-verdict.test.ts | 16 +- ...red-agent-session-codex-input-host.test.ts | 3 +- ...ured-agent-session-crash-mid-start.test.ts | 14 +- ...tured-agent-session-crash-turn-end.test.ts | 15 +- .../structured-agent-session-host-delivery.ts | 1 + ...ructured-agent-session-host-status-feed.ts | 2 + .../structured-agent-session-host-tabs.ts | 61 +- .../structured-agent-session-host.ts | 33 +- ...gent-session-legacy-handoff-record.test.ts | 9 +- ...nt-session-open-test-chats.test-support.ts | 13 + ...uctured-agent-session-read-restore.test.ts | 102 ---- .../structured-agent-session-read-restore.ts | 21 - ...ed-agent-session-readable-restorer.test.ts | 68 --- ...uctured-agent-session-readable-restorer.ts | 34 -- ...tured-agent-session-recovery-exits.test.ts | 17 +- ...agent-session-replaced-server-turn.test.ts | 11 +- ...ctured-agent-session-restart-candidates.ts | 12 +- ...ed-agent-session-restart-failure-ledger.ts | 2 - ...t-session-restart-offer-newer-orca.test.ts | 18 +- ...t-session-restart-offer-withdrawal.test.ts | 10 +- ...nt-session-restart-provider-output.test.ts | 9 +- ...uctured-agent-session-restart-reconcile.ts | 40 ++ ...nt-session-restart-recovery-output.test.ts | 5 +- ...agent-session-restart-restore-gate.test.ts | 27 - ...ured-agent-session-restart-restore-gate.ts | 17 - ...ured-agent-session-restart-restore.test.ts | 291 ---------- ...tructured-agent-session-restart-restore.ts | 93 --- ...tured-agent-session-restart-resume-host.ts | 2 - ...nt-session-restart-resume-progress.test.ts | 2 + ...nt-session-restart-stream-duration.test.ts | 13 +- .../structured-agent-session-reveal.ts | 52 +- ...uctured-agent-session-saved-status.test.ts | 370 ++++++++++++ .../structured-agent-session-saved-status.ts | 195 +++++++ ...agent-session-send-open-stale-turn.test.ts | 3 +- ...red-agent-session-shutdown-cut-row.test.ts | 11 +- ...ured-agent-session-stale-turn-open.test.ts | 3 +- ...-session-startup-reconcile-failure.test.ts | 21 +- ...ructured-agent-session-status-feed-deps.ts | 16 + .../structured-agent-session-status-feed.ts | 30 +- ...ructured-agent-session-status-ownership.ts | 13 +- ...structured-agent-session-status-summary.ts | 13 + ...uctured-agent-session-unopened-tab.test.ts | 6 +- ...red-agent-session-unsent-send-hold.test.ts | 3 +- ...t-session-wedged-profile-migration.test.ts | 32 +- ...t-session-workspace-sleep-lifetime.test.ts | 5 +- .../structured-conversation-command.test.ts | 5 +- .../agent-session-founding-admission.ts | 7 +- .../runtime/agent-session-founding-store.ts | 10 +- .../agent-session-inventory-scale.test.ts | 34 +- .../agent-session-record-store-slot.test.ts | 1 + .../agent-session-record-store-slot.ts | 7 +- ...agent-session-record-store-test-harness.ts | 5 +- .../runtime/agent-session-record-store.ts | 71 +-- ...agent-session-saved-roster.test-support.ts | 108 ++++ .../agent-session-saved-roster.test.ts | 290 ++++++++++ .../agent-session-saved-status-scale.test.ts | 133 +++++ .../agent-session-status-cut-scale.test.ts | 280 +++++++++ ...nt-session-status-cut-work.test-support.ts | 103 ++++ ...-session-status-write-work.test-support.ts | 139 +++++ .../agent-session-store-transactions.ts | 3 +- .../runtime/agent-session-tab-layout-queue.ts | 9 +- .../agent-session/agent-session-tab-table.ts | 138 ++++- .../agent-session-reservation-admission.ts | 21 +- ...red-agent-session-runtime-teardown.test.ts | 22 +- ...ructured-agent-session-runtime-teardown.ts | 1 + ...structured-agent-session-tab-index-seed.ts | 35 -- .../construction/runtime-construction.ts | 7 + .../construction/runtime-identity-state.ts | 2 + ...runtime-structured-session-restore.test.ts | 245 +++++--- .../mobile-session-tabs-part-06.spec.ts | 4 + .../mobile-session-tabs-part-07.spec.ts | 2 + ...stration-structured-worker-session.test.ts | 40 +- ...orchestration-structured-worker-session.ts | 14 +- ...on-structured-worker-start-failure.test.ts | 15 +- .../rpc/methods/session-tabs-inventory.ts | 4 +- src/main/runtime/rpc/methods/session-tabs.ts | 8 +- ...red-agent-session-saved-status-rpc.test.ts | 77 +++ .../structured-agent-session-status-stream.ts | 4 +- .../methods/structured-session-tab-restore.ts | 6 +- .../structured-worker-tab-retirement.test.ts | 5 +- ...ime-agent-orchestration-projection.test.ts | 1 + ...ntime-mobile-structured-tab-status.test.ts | 46 ++ .../session-tabs/restored-chat-tab-changes.ts | 71 ++- .../structured-session-tab-close.ts | 45 +- .../structured-session-tab-restoration.ts | 36 +- ...ructured-agent-session-held-signal.test.ts | 1 + ...uctured-agent-session-host-refusal.test.ts | 17 +- ...tured-agent-session-mobile-restore.test.ts | 5 +- ...ructured-agent-session-runtime-contract.ts | 1 + ...uctured-agent-session-runtime-exit.test.ts | 3 +- .../structured-agent-session-runtime.ts | 1 + ...-agent-session-startup-tab-restore.test.ts | 312 ++++++++-- ...-agent-session-tab-authority-scale.test.ts | 203 +++++++ ...gent-session-tab-authority.test-support.ts | 251 ++++++++ ...ctured-agent-session-tab-authority.test.ts | 538 ++++++++++++++++++ ...ent-session-windowless-layout-host.test.ts | 38 +- ...ed-agent-session-windowless-layout.test.ts | 15 +- src/main/runtime/structured-worker-custody.ts | 3 + .../workspaces/workspace-process-listing.ts | 2 + ...agent-session-status-bridge-clock.test.tsx | 24 + src/shared/agent-session-last-turn.ts | 36 ++ .../agent-session-legacy-handoff-lease.ts | 3 +- .../agent-session-record-stored-form.test.ts | 26 + .../agent-session-record-stored-form.ts | 4 +- src/shared/agent-session-record.ts | 3 + ...agent-session-saved-status.test-fixture.ts | 58 ++ .../native-chat/native-chat-orca-stop-cut.ts | 18 +- ...structured-agent-session-latest-request.ts | 6 +- .../structured-agent-session-projection.ts | 7 +- ...ed-agent-session-status-started-at.test.ts | 4 +- ...ss-version-agent-session-wire.unit.test.ts | 6 +- ...-chat-owner-status-activation.unit.test.ts | 5 +- ...permission-report-publication.unit.test.ts | 5 +- ...session-tab-close-regressions.unit.test.ts | 97 +++- 123 files changed, 4323 insertions(+), 1277 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status.ts create mode 100644 src/main/runtime/agent-session-saved-roster.test-support.ts create mode 100644 src/main/runtime/agent-session-saved-roster.test.ts create mode 100644 src/main/runtime/agent-session-saved-status-scale.test.ts create mode 100644 src/main/runtime/agent-session-status-cut-scale.test.ts create mode 100644 src/main/runtime/agent-session-status-cut-work.test-support.ts create mode 100644 src/main/runtime/agent-session-status-write-work.test-support.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-saved-status-rpc.test.ts create mode 100644 src/main/runtime/structured-agent-session-tab-authority-scale.test.ts create mode 100644 src/main/runtime/structured-agent-session-tab-authority.test-support.ts create mode 100644 src/main/runtime/structured-agent-session-tab-authority.test.ts create mode 100644 src/shared/agent-session-last-turn.ts create mode 100644 src/shared/agent-session-saved-status.test-fixture.ts 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)