From c867bd2c30d0c21be8365df2a56200d8794d0e57 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:28:09 -0700 Subject: [PATCH 001/101] refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. --- config/ts-nocheck-baseline.txt | 7 +- .../mobile-structured-send-delivery.ts | 2 +- ...tured-agent-session-prompt-cancel.test.tsx | 3 +- .../claude-structured-session-acquisition.ts | 2 +- .../claude-transcript-entry-uuid.test.ts | 21 + .../claude/claude-transcript-entry-uuid.ts | 18 + src/main/claude/claude-tui-exit.test.ts | 159 ---- src/main/claude/claude-tui-exit.ts | 119 --- .../claude/claude-tui-resume-launch.test.ts | 227 ------ src/main/claude/claude-tui-resume-launch.ts | 101 --- .../claude/claude-tui-resume-proof.test.ts | 80 -- src/main/claude/claude-tui-resume-proof.ts | 111 --- ...tui-resume-real-binary.integration.test.ts | 286 ------- .../codex/codex-resume-process-proof.test.ts | 123 --- src/main/codex/codex-resume-process-proof.ts | 101 --- ...tui-resume-real-binary.integration.test.ts | 160 ---- .../codex/codex-tui-rollout-proof.test.ts | 136 +--- src/main/codex/codex-tui-rollout-proof.ts | 160 ---- src/main/ipc/pty/runtime/controller.ts | 3 - src/main/ipc/pty/runtime/operations.ts | 15 - .../structured-agent-session-attach.ts | 4 +- ...d-agent-session-background-task-channel.ts | 4 - ...agent-session-claude-option-queue.test.ts} | 87 +-- ...-agent-session-command-publication.test.ts | 16 +- ...uctured-agent-session-dead-tui-recovery.ts | 35 - .../structured-agent-session-eviction.ts | 4 +- ...uctured-agent-session-handoff-admission.ts | 138 ---- ...ured-agent-session-handoff-flow-context.ts | 86 --- ...-agent-session-handoff-flow-runner.test.ts | 131 ---- ...tured-agent-session-handoff-flow-runner.ts | 114 --- ...tructured-agent-session-handoff-forward.ts | 232 ------ ...nt-session-handoff-operation-guard.test.ts | 221 ------ ...d-agent-session-handoff-operation-guard.ts | 125 --- ...ured-agent-session-handoff-options.test.ts | 317 -------- ...tructured-agent-session-handoff-options.ts | 29 - ...-agent-session-handoff-owner-close.test.ts | 52 -- ...tured-agent-session-handoff-owner-close.ts | 36 - ...tured-agent-session-handoff-queue-start.ts | 46 -- .../structured-agent-session-handoff-queue.ts | 133 ---- ...tructured-agent-session-handoff-recover.ts | 30 - ...tured-agent-session-handoff-restart-tui.ts | 111 --- ...tructured-agent-session-handoff-restart.ts | 309 -------- ...structured-agent-session-handoff-result.ts | 32 - ...ured-agent-session-handoff-revalidation.ts | 52 -- ...ured-agent-session-handoff-reverse.test.ts | 100 --- ...tructured-agent-session-handoff-reverse.ts | 155 ---- .../structured-agent-session-handoff-state.ts | 38 - ...structured-agent-session-handoff-status.ts | 166 ---- ...-agent-session-handoff-test-coordinator.ts | 81 -- ...d-agent-session-handoff-test-identities.ts | 40 - ...red-agent-session-handoff-test-requests.ts | 53 -- .../structured-agent-session-handoff-types.ts | 122 --- .../structured-agent-session-handoff.test.ts | 597 -------------- .../structured-agent-session-handoff.ts | 286 ------- ...ed-agent-session-host-handoff-stop.test.ts | 81 -- ...uctured-agent-session-host-handoff.test.ts | 602 --------------- .../structured-agent-session-host-handoff.ts | 217 ------ ...d-agent-session-host-runtime-state.test.ts | 7 +- ...ctured-agent-session-host-runtime-state.ts | 4 - ...ctured-agent-session-host-teardown.test.ts | 59 ++ .../structured-agent-session-host-teardown.ts | 23 +- .../structured-agent-session-host-types.ts | 10 +- .../structured-agent-session-host.test.ts | 3 +- .../structured-agent-session-host.ts | 57 +- ...ctured-agent-session-lease-renewer.test.ts | 41 +- .../structured-agent-session-lease-renewer.ts | 41 +- ...gent-session-legacy-handoff-record.test.ts | 214 +++++ ...-session-live-tui-restart-survival.test.ts | 248 ------ ...tructured-agent-session-manual-recovery.ts | 103 --- ...gent-session-native-handoff-acquisition.ts | 133 ---- ...ed-agent-session-option-settlement.test.ts | 77 +- .../structured-agent-session-owner-status.ts | 33 + ...ed-agent-session-proven-dead-retry.test.ts | 202 ----- .../structured-agent-session-read-restore.ts | 8 +- ...ed-agent-session-readable-restorer.test.ts | 3 +- ...uctured-agent-session-readable-restorer.ts | 1 - ...-agent-session-recovery-resolution.test.ts | 26 +- ...tured-agent-session-recovery-resolution.ts | 7 +- ...ctured-agent-session-refusal-retry.test.ts | 34 +- ...ured-agent-session-restart-restore.test.ts | 20 +- ...tructured-agent-session-restart-restore.ts | 9 +- ...ctured-agent-session-resume-eligibility.ts | 3 +- .../structured-agent-session-reveal.test.ts | 6 +- .../structured-agent-session-reveal.ts | 2 +- ...red-agent-session-send-preparation.test.ts | 6 +- ...ructured-agent-session-settlement-retry.ts | 6 +- ...ructured-agent-session-subscribers.test.ts | 55 +- .../structured-agent-session-subscribers.ts | 28 +- ...red-agent-session-surface-lifetime.test.ts | 142 +--- ...ent-session-teardown-handoff-drain.test.ts | 227 ------ ...tured-agent-session-wire-admission.test.ts | 10 +- .../structured-tui-transcript-boundary.ts | 60 -- .../structured-tui-transcript-catchup.test.ts | 167 ---- .../structured-tui-transcript-catchup.ts | 299 ------- .../structured-tui-transcript-close.test.ts | 168 ---- ...tructured-tui-transcript-ownership.test.ts | 130 ---- ...ed-tui-transcript-teardown-test-fixture.ts | 104 --- ...structured-tui-transcript-teardown.test.ts | 190 ----- .../native-chat/session-file-resolver.test.ts | 17 +- src/main/native-chat/session-file-resolver.ts | 16 - ...-session-handoff-lease-transitions.test.ts | 66 -- ...agent-session-handoff-lease-transitions.ts | 187 ----- ...gent-session-handoff-record-transitions.ts | 109 --- .../agent-session-lease-transitions.ts | 25 - .../agent-session-pty-write-gate.test.ts | 72 -- .../runtime/agent-session-pty-write-gate.ts | 23 - .../agent-session-record-store.test.ts | 22 - ...ssion-restart-handoff-adjudication.test.ts | 172 ----- ...nt-session-restart-handoff-adjudication.ts | 79 -- ...agent-session-restart-lease-transitions.ts | 32 +- .../runtime/agent-session-resume-args.test.ts | 33 - src/main/runtime/agent-session-resume-args.ts | 17 - ...ude-structured-session-integration.test.ts | 168 +--- ...ca-runtime-create-terminal-dependencies.ts | 1 - .../runtime/orca-runtime-create-terminal.ts | 6 - ...e-get-agent-session-execution-namespace.ts | 42 +- ...tructured-agent-session-create-support.ts} | 60 +- .../runtime/orca-runtime-get-worktree-ps.ts | 61 +- ...ve-recovered-structured-tui-pty-process.ts | 197 ----- ...tore-structured-agent-session-tabs-once.ts | 4 +- ...runtime-stop-structured-session-process.ts | 204 ----- ...ime-structured-agent-session-launch-tui.ts | 154 ---- ...uctured-agent-session-recover-tui-owner.ts | 204 ----- ...uctured-agent-session-reprove-tui-owner.ts | 75 -- ...runtime-structured-tui-tab-binding.test.ts | 730 ------------------ .../agent-status-producer-census.test.ts | 5 - ...ession-gate-classification.test-fixture.ts | 4 - .../methods/structured-agent-session-gate.ts | 2 +- ...ructured-agent-session-rpc.test-fixture.ts | 15 - .../structured-agent-session-schemas.ts | 1 - .../methods/structured-agent-session.test.ts | 17 +- .../rpc/methods/structured-agent-session.ts | 6 - .../runtime-extraction-regressions.test.ts | 13 +- .../runtime-pty-controller-contract.ts | 5 - .../runtime/runtime-terminal-contracts.ts | 1 - ...tructured-agent-session-create-adoption.ts | 2 +- ...ructured-agent-session-pty-binding.test.ts | 69 -- .../structured-agent-session-runtime.ts | 9 - .../runtime/structured-tui-exit-proof.test.ts | 91 --- src/main/runtime/structured-tui-exit-proof.ts | 44 -- .../structured-tui-idle-evidence.test.ts | 27 - .../runtime/structured-tui-idle-evidence.ts | 9 - .../structured-tui-process-identity.test.ts | 290 ------- .../structured-tui-process-identity.ts | 242 ------ ...tructured-tui-recovery-claim-match.test.ts | 110 --- .../structured-tui-recovery-claim-match.ts | 89 --- ...ructuredAgentSessionHandoffChrome.test.tsx | 58 -- .../StructuredAgentSessionHandoffChrome.tsx | 225 ------ ...se-native-chat-provisional-launch.test.tsx | 1 - .../use-structured-agent-session-mutate.ts | 5 +- ...uctured-agent-session-provisional.test.tsx | 1 - ...cturedAgentSessionTerminalReturnButton.tsx | 25 - src/renderer/src/i18n/locales/en.json | 32 - src/renderer/src/i18n/locales/fr.json | 32 - src/renderer/src/i18n/locales/ja.json | 32 - src/renderer/src/i18n/locales/ko.json | 32 - src/renderer/src/i18n/locales/zh.json | 32 - .../worktree-agent-activation-gate.test.ts | 127 +-- .../src/lib/worktree-agent-activation-gate.ts | 30 +- .../worktree-agent-structured-inventory.ts | 43 +- .../structured-agent-session-handoff-store.ts | 50 -- .../agent-session-definitive-refusal.test.ts | 9 +- .../agent-session-lease-adjudication.test.ts | 42 +- .../agent-session-lease-adjudication.ts | 34 +- src/shared/agent-session-mutation-envelope.ts | 6 - src/shared/agent-session-record.ts | 5 + src/shared/agent-session-refusal-retry.ts | 11 - src/shared/agent-session-wire.ts | 22 +- .../rpc-params-catalog.generated.ts | 2 - .../structured-agent-session-params.ts | 11 +- .../structured-agent-session-coalescer.ts | 1 - src/shared/structured-agent-session-outbox.ts | 2 +- .../structured-agent-session-reducer.ts | 23 +- .../structured-agent-session-host-fixture.ts | 1 - ...ructured-agent-session-surface-manifest.ts | 13 - 175 files changed, 555 insertions(+), 13861 deletions(-) create mode 100644 src/main/claude/claude-transcript-entry-uuid.test.ts create mode 100644 src/main/claude/claude-transcript-entry-uuid.ts delete mode 100644 src/main/claude/claude-tui-exit.test.ts delete mode 100644 src/main/claude/claude-tui-exit.ts delete mode 100644 src/main/claude/claude-tui-resume-launch.test.ts delete mode 100644 src/main/claude/claude-tui-resume-launch.ts delete mode 100644 src/main/claude/claude-tui-resume-proof.test.ts delete mode 100644 src/main/claude/claude-tui-resume-proof.ts delete mode 100644 src/main/claude/claude-tui-resume-real-binary.integration.test.ts delete mode 100644 src/main/codex/codex-resume-process-proof.test.ts delete mode 100644 src/main/codex/codex-resume-process-proof.ts delete mode 100644 src/main/codex/codex-tui-resume-real-binary.integration.test.ts rename src/main/native-chat/agent-session-wire/{structured-agent-session-claude-options-round-trip.test.ts => structured-agent-session-claude-option-queue.test.ts} (57%) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff-stop.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-native-handoff-acquisition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-close.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts delete mode 100644 src/main/runtime/agent-session-handoff-lease-transitions.test.ts delete mode 100644 src/main/runtime/agent-session-handoff-lease-transitions.ts delete mode 100644 src/main/runtime/agent-session-handoff-record-transitions.ts delete mode 100644 src/main/runtime/agent-session-restart-handoff-adjudication.test.ts delete mode 100644 src/main/runtime/agent-session-restart-handoff-adjudication.ts delete mode 100644 src/main/runtime/agent-session-resume-args.test.ts delete mode 100644 src/main/runtime/agent-session-resume-args.ts rename src/main/runtime/{orca-runtime-resolve-recovered-structured-tui-transcript.ts => orca-runtime-get-structured-agent-session-create-support.ts} (81%) delete mode 100644 src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts delete mode 100644 src/main/runtime/orca-runtime-stop-structured-session-process.ts delete mode 100644 src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts delete mode 100644 src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts delete mode 100644 src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts delete mode 100644 src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts delete mode 100644 src/main/runtime/structured-agent-session-pty-binding.test.ts delete mode 100644 src/main/runtime/structured-tui-exit-proof.test.ts delete mode 100644 src/main/runtime/structured-tui-exit-proof.ts delete mode 100644 src/main/runtime/structured-tui-idle-evidence.test.ts delete mode 100644 src/main/runtime/structured-tui-idle-evidence.ts delete mode 100644 src/main/runtime/structured-tui-process-identity.test.ts delete mode 100644 src/main/runtime/structured-tui-process-identity.ts delete mode 100644 src/main/runtime/structured-tui-recovery-claim-match.test.ts delete mode 100644 src/main/runtime/structured-tui-recovery-claim-match.ts delete mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx delete mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx delete mode 100644 src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx delete mode 100644 src/renderer/src/runtime/structured-agent-session-handoff-store.ts diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index e897af7387cc..7b102bdf419f 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -42,6 +42,7 @@ src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts src/main/runtime/orca-runtime-get-runtime-id.ts +src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -75,7 +76,6 @@ src/main/runtime/orca-runtime-pick-most-recent-actor.ts src/main/runtime/orca-runtime-postlude.ts src/main/runtime/orca-runtime-prepare-pty-execution-context.ts src/main/runtime/orca-runtime-preserved-branch-cleanup.ts -src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts src/main/runtime/orca-runtime-pty-foreground-process-reads.ts src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts @@ -96,7 +96,6 @@ src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktre src/main/runtime/orca-runtime-resolve-exit-waiters.ts src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts -src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts src/main/runtime/orca-runtime-resolve-terminal-pane.ts src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts src/main/runtime/orca-runtime-resolve-waiter.ts @@ -120,12 +119,8 @@ src/main/runtime/orca-runtime-state-fields.ts src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts src/main/runtime/orca-runtime-stop-requested-pty-ids.ts -src/main/runtime/orca-runtime-stop-structured-session-process.ts src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts -src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts -src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts -src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts src/main/runtime/orca-runtime-sync-window-graph.ts diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 30fb4c44ae22..70f8a8f0d3d7 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -43,7 +43,7 @@ export function mobileStructuredSendDelivery( return { outcome: 'unknown', operationIdSpent: false, error: null } } if (result.status === 'refused') { - const refusalState = agentSessionRefusalOperationState('agentSession.send', result.code) + const refusalState = agentSessionRefusalOperationState(result.code) if (refusalState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 48b44e78ff5d..2ce48ff908da 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -132,8 +132,7 @@ describe('mobile structured prompt cancellation', () => { submissions: [], retainedItemLimit: 1024, hasOlder: false, - status: 'ready', - handoff: null + status: 'ready' } mocks.sendRequest.mockResolvedValue({ ok: true, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b740d89e335c..b2efed691ee9 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -30,7 +30,7 @@ import { type ClaudeAcquireCallbacks } from './claude-structured-session-state' import { resolveClaudeAcquisitionError } from './claude-structured-session-close' -import { readClaudeTranscriptEntryUuid } from './claude-tui-exit' +import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch' diff --git a/src/main/claude/claude-transcript-entry-uuid.test.ts b/src/main/claude/claude-transcript-entry-uuid.test.ts new file mode 100644 index 000000000000..17067da8747f --- /dev/null +++ b/src/main/claude/claude-transcript-entry-uuid.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' + +describe('Claude transcript entry uuid', () => { + it('does not sample UUIDs from subagent stdout frames with a parent tool use', () => { + expect( + readClaudeTranscriptEntryUuid({ + type: 'assistant', + uuid: 'subagent-assistant', + parent_tool_use_id: 'parent-tool' + }) + ).toBeNull() + expect( + readClaudeTranscriptEntryUuid({ + type: 'assistant', + uuid: 'main-assistant', + parent_tool_use_id: null + }) + ).toBe('main-assistant') + }) +}) diff --git a/src/main/claude/claude-transcript-entry-uuid.ts b/src/main/claude/claude-transcript-entry-uuid.ts new file mode 100644 index 000000000000..c845703017bb --- /dev/null +++ b/src/main/claude/claude-transcript-entry-uuid.ts @@ -0,0 +1,18 @@ +function validLeafUuid(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > 512) { + return null + } + const hasControlCharacter = [...value].some((character) => { + const code = character.codePointAt(0) ?? 0 + return code <= 0x1f || code === 0x7f + }) + return value === value.trim() && !hasControlCharacter ? value : null +} + +export function readClaudeTranscriptEntryUuid(value: Record): string | null { + return value.isSidechain === true || + value.parent_tool_use_id != null || + (value.type !== 'user' && value.type !== 'assistant') + ? null + : validLeafUuid(value.uuid) +} diff --git a/src/main/claude/claude-tui-exit.test.ts b/src/main/claude/claude-tui-exit.test.ts deleted file mode 100644 index 6e1140b0f4db..000000000000 --- a/src/main/claude/claude-tui-exit.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - completeClaudeTuiExit, - readClaudeTranscriptEntryUuid, - readClaudeTranscriptLeafUuid -} from './claude-tui-exit' - -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('Claude TUI exit', () => { - it('does not sample UUIDs from subagent stdout frames with a parent tool use', () => { - expect( - readClaudeTranscriptEntryUuid({ - type: 'assistant', - uuid: 'subagent-assistant', - parent_tool_use_id: 'parent-tool' - }) - ).toBeNull() - expect( - readClaudeTranscriptEntryUuid({ - type: 'assistant', - uuid: 'main-assistant', - parent_tool_use_id: null - }) - ).toBe('main-assistant') - }) - - it('reads the authoritative last-prompt leaf from a transcript tail', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-')) - roots.push(root) - const transcriptPath = join(root, 'session.jsonl') - await writeFile( - transcriptPath, - [ - { type: 'user', uuid: 'user-one' }, - { type: 'assistant', uuid: 'assistant-one' }, - { type: 'last-prompt', leafUuid: 'chain-head' }, - { type: 'file-history-snapshot', snapshot: {} } - ] - .map((entry) => JSON.stringify(entry)) - .join('\n') - ) - - await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('chain-head') - }) - - it('falls back to the last persisted message when last-prompt metadata is absent', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-')) - roots.push(root) - const transcriptPath = join(root, 'session.jsonl') - await writeFile( - transcriptPath, - [ - { type: 'user', uuid: 'user-one' }, - { type: 'assistant', uuid: 'assistant-one' }, - { type: 'system', subtype: 'init', uuid: 'init-frame' }, - { type: 'result', uuid: 'result-frame' }, - { type: 'stream_event', uuid: 'stream-event-frame' } - ] - .map((entry) => JSON.stringify(entry)) - .join('\n') - ) - - await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('assistant-one') - }) - - it('ignores sidechain messages when selecting a fallback transcript leaf', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-sidechain-leaf-')) - const transcriptPath = join(root, 'session.jsonl') - await writeFile( - transcriptPath, - [ - { type: 'assistant', uuid: 'main-assistant' }, - { type: 'assistant', uuid: 'subagent-assistant', isSidechain: true }, - { type: 'result', uuid: 'result-frame' } - ] - .map((entry) => JSON.stringify(entry)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('main-assistant') - }) - - it('persists the resumed chain head only after the exact Claude child exits', async () => { - let resolveExit!: (exit: { - pid: number - exitCode: number | null - signal: string | null - }) => void - const exitPromise = new Promise<{ - pid: number - exitCode: number | null - signal: string | null - }>((resolve) => { - resolveExit = resolve - }) - const persistHandle = vi.fn(async () => undefined) - const completion = completeClaudeTuiExit({ - childPid: 4210, - waitForChildExit: () => exitPromise, - sessionId: 'provider-session', - transcriptPath: '/accounts/claude/session.jsonl', - fence: 7, - persistHandle, - readLeafUuid: async () => 'tui-leaf', - linkId: 'tui-resumed-link', - now: () => 12 - }) - - expect(persistHandle).not.toHaveBeenCalled() - resolveExit({ pid: 4210, exitCode: 0, signal: null }) - - await expect(completion).resolves.toMatchObject({ - link: { - linkId: 'tui-resumed-link', - handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'tui-leaf' }, - origin: 'resumed', - mintedAtFence: 7, - observedAt: 12 - } - }) - expect(persistHandle).toHaveBeenCalledTimes(1) - }) - - it('refuses another process exit and a missing transcript leaf', async () => { - const persistHandle = vi.fn(async () => undefined) - await expect( - completeClaudeTuiExit({ - childPid: 4210, - waitForChildExit: async () => ({ pid: 4211, exitCode: 0, signal: null }), - sessionId: 'provider-session', - transcriptPath: '/session.jsonl', - fence: 2, - persistHandle, - readLeafUuid: async () => 'leaf' - }) - ).rejects.toThrow(/did not belong to the Claude child/) - await expect( - completeClaudeTuiExit({ - childPid: 4210, - waitForChildExit: async () => ({ pid: 4210, exitCode: 1, signal: null }), - sessionId: 'provider-session', - transcriptPath: '/session.jsonl', - fence: 2, - persistHandle, - readLeafUuid: async () => null - }) - ).rejects.toThrow(/resumable transcript leaf/) - expect(persistHandle).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/claude/claude-tui-exit.ts b/src/main/claude/claude-tui-exit.ts deleted file mode 100644 index 3772e9c5e52f..000000000000 --- a/src/main/claude/claude-tui-exit.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { open } from 'node:fs/promises' -import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' -import { claudeProviderHandleLink } from './claude-structured-owner-identity' - -const TRANSCRIPT_TAIL_CHUNK_BYTES = 64 * 1024 -const TRANSCRIPT_TAIL_READ_LIMIT_BYTES = 4 * 1024 * 1024 - -type TranscriptLeafCandidate = { leafUuid: string; authoritative: boolean } - -function validLeafUuid(value: unknown): string | null { - if (typeof value !== 'string' || value.length === 0 || value.length > 512) { - return null - } - const hasControlCharacter = [...value].some((character) => { - const code = character.codePointAt(0) ?? 0 - return code <= 0x1f || code === 0x7f - }) - return value === value.trim() && !hasControlCharacter ? value : null -} - -export function readClaudeTranscriptEntryUuid(value: Record): string | null { - return value.isSidechain === true || - value.parent_tool_use_id != null || - (value.type !== 'user' && value.type !== 'assistant') - ? null - : validLeafUuid(value.uuid) -} - -function readLeafCandidate(line: string): TranscriptLeafCandidate | null { - try { - const value = JSON.parse(line) as Record - const lastPromptLeaf = value.type === 'last-prompt' ? validLeafUuid(value.leafUuid) : null - if (lastPromptLeaf) { - return { leafUuid: lastPromptLeaf, authoritative: true } - } - const messageLeaf = readClaudeTranscriptEntryUuid(value) - return messageLeaf ? { leafUuid: messageLeaf, authoritative: false } : null - } catch { - return null - } -} - -export async function readClaudeTranscriptLeafUuid(transcriptPath: string): Promise { - const file = await open(transcriptPath, 'r') - try { - const { size } = await file.stat() - let position = size - let suffix = '' - let fallback: string | null = null - let scanned = 0 - while (position > 0 && scanned < TRANSCRIPT_TAIL_READ_LIMIT_BYTES) { - const length = Math.min(TRANSCRIPT_TAIL_CHUNK_BYTES, position) - position -= length - scanned += length - const buffer = Buffer.alloc(length) - await file.read(buffer, 0, length, position) - const lines = `${buffer.toString('utf8')}${suffix}`.split(/\r?\n/) - suffix = position > 0 ? (lines.shift() ?? '') : '' - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]?.trim() - if (!line) { - continue - } - const candidate = readLeafCandidate(line) - if (!candidate) { - continue - } - if (candidate.authoritative) { - return candidate.leafUuid - } - fallback ??= candidate.leafUuid - } - } - return fallback - } finally { - await file.close() - } -} - -export type ClaudeTuiChildExit = { - pid: number - exitCode: number | null - signal: string | null -} - -export async function completeClaudeTuiExit(input: { - childPid: number - waitForChildExit: () => Promise - sessionId: string - transcriptPath: string - fence: number - persistHandle: (link: AgentSessionProviderHandleLink) => Promise - readLeafUuid?: (transcriptPath: string) => Promise - linkId?: string - now?: () => number -}): Promise<{ - exit: ClaudeTuiChildExit - transcriptPath: string - link: AgentSessionProviderHandleLink -}> { - const exit = await input.waitForChildExit() - if (exit.pid !== input.childPid) { - throw new Error('The observed process exit did not belong to the Claude child.') - } - const leafUuid = await (input.readLeafUuid ?? readClaudeTranscriptLeafUuid)(input.transcriptPath) - if (!leafUuid) { - throw new Error('The exited Claude TUI did not persist a resumable transcript leaf.') - } - const link = claudeProviderHandleLink({ - sessionId: input.sessionId, - leafUuid, - resumed: true, - fence: input.fence, - ...(input.linkId ? { linkId: input.linkId } : {}), - observedAt: input.now?.() ?? Date.now() - }) - await input.persistHandle(link) - return { exit, transcriptPath: input.transcriptPath, link } -} diff --git a/src/main/claude/claude-tui-resume-launch.test.ts b/src/main/claude/claude-tui-resume-launch.test.ts deleted file mode 100644 index f907d1fcb4ee..000000000000 --- a/src/main/claude/claude-tui-resume-launch.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' -import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' -import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' -import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch' - -function record(overrides: Partial = {}): AgentSessionRecord { - return { - sessionId: 'orca-session-1', - provider: 'claude', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-folder', - workspaceKind: 'folder' - }, - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude-one' }, - providerHandleChain: [ - { - linkId: 'created', - handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-one' }, - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ], - ...overrides - } as AgentSessionRecord -} - -function makeExecutable(path: string): void { - mkdirSync(join(path, '..'), { recursive: true }) - writeFileSync(path, '') - if (process.platform !== 'win32') { - chmodSync(path, 0o755) - } -} - -describe('Claude TUI resume launch', () => { - it('pins the workspace, account home, setting sources, and launch identity', async () => { - const build = createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async (workspaceId) => `/workspaces/${workspaceId}`, - resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), - resolveEnv: () => ({ - SELECTED_ACCOUNT: 'one', - ANTHROPIC_AUTH_TOKEN: 'selected-account-token' - }), - inheritedEnv: { - ANTHROPIC_API_KEY: 'inherited-gateway-key', - ANTHROPIC_BASE_URL: 'https://inherited-gateway.invalid', - CLAUDE_CODE_SESSION_ID: 'parent-session', - SAFE_PARENT: 'kept' - } - }) - - const launch = await build({ record: record(), spawnToken: 'spawn-one' }) - - expect(launch).toMatchObject({ - command: '/usr/local/bin/claude', - args: [ - '--setting-sources', - CLAUDE_DEFAULT_SETTING_SOURCES.join(','), - '--resume', - 'provider-session' - ], - cwd: '/workspaces/workspace-folder', - providerSessionId: 'provider-session', - resumeLeafUuid: 'leaf-one' - }) - expect(launch.env).toMatchObject({ - SAFE_PARENT: 'kept', - SELECTED_ACCOUNT: 'one', - CLAUDE_CONFIG_DIR: '/accounts/claude-one', - ORCA_AGENT_LAUNCH_TOKEN: 'spawn-one', - [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-one', - ANTHROPIC_AUTH_TOKEN: 'selected-account-token' - }) - // System auth (the only state an explicit ANTHROPIC_AUTH_TOKEN overlay is legal in): - // the user's own inherited key is their sign-in and survives. The managed-account - // half — where it is stripped — is covered by 'structured-to-TUI handoff auth'. - expect(launch.env.ANTHROPIC_API_KEY).toBe('inherited-gateway-key') - // Endpoint selection is not credential material; the existing adapter pinning preserves it. - expect(launch.env.ANTHROPIC_BASE_URL).toBe('https://inherited-gateway.invalid') - expect(launch.env.CLAUDE_CODE_SESSION_ID).toBeUndefined() - }) - - it('pairs the resumed Claude CLI with its sibling Node runtime', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-resume-')) - const binDir = join(root, 'bin') - const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude') - const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node') - makeExecutable(claudeCommand) - makeExecutable(nodeCommand) - - const build = createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/workspace', - resolveCommand: () => claudeCommand, - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnv: () => ({ PATH: '/usr/bin' }), - inheritedEnv: {} - }) - - const launch = await build({ record: record(), spawnToken: 'spawn' }) - - expect((launch.env.PATH ?? launch.env.Path)?.split(delimiter)[0]).toBe(binDir) - }) - - it('uses the durable session environment instead of current account settings', async () => { - const build = createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/workspace', - resolveCommand: () => 'claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), - resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'pinned-token' }), - inheritedEnv: {} - }) - - const launch = await build({ record: record(), spawnToken: 'spawn' }) - - expect(launch.env.ANTHROPIC_AUTH_TOKEN).toBe('pinned-token') - }) - - it('resolves the durable chain head instead of an earlier Claude leaf', async () => { - const nextRecord = record({ - providerHandleChain: [ - ...record().providerHandleChain, - { - linkId: 'resumed', - handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-two' }, - origin: 'resumed', - mintedAtFence: 2, - observedAt: 2 - } - ] - }) - const build = createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/workspace', - resolveCommand: () => 'claude', - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - inheritedEnv: {} - }) - - await expect(build({ record: nextRecord, spawnToken: 'spawn-two' })).resolves.toMatchObject({ - providerSessionId: 'provider-session', - resumeLeafUuid: 'leaf-two' - }) - }) - - it('preserves durable Claude launch arguments before resume defaults', async () => { - const build = createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/workspace', - resolveCommand: () => 'claude', - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - inheritedEnv: {} - }) - - const launch = await build({ - record: record({ launchArgs: ['--model', 'claude-sonnet-4-5'] }), - spawnToken: 'spawn' - }) - - expect(launch.args.slice(0, 3)).toEqual(['--model', 'claude-sonnet-4-5', '--setting-sources']) - }) - - it('rejects missing Claude handles and unpinned account homes', async () => { - const build = createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/workspace', - resolveCommand: () => 'claude', - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - inheritedEnv: {} - }) - - await expect( - build({ record: record({ providerHandleChain: [] }), spawnToken: 'spawn' }) - ).rejects.toThrow('claude_tui_resume_handle_required') - await expect( - build({ - record: record({ accountHome: { variable: 'CODEX_HOME', path: '/wrong' } }), - spawnToken: 'spawn' - }) - ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) - }) -}) - -// buildClaudeChildProcessEnv strips its inherited half unconditionally, so this module -// would have signed a system-auth user out of the session the structured path had just -// honoured. It is not wired up yet; the required policy is what stops the next caller -// from inheriting that. -describe('structured-to-TUI handoff auth', () => { - it('carries a system-auth user their own inherited credential', async () => { - const launch = await createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/repos/workspace-1', - resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), - inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' } - })({ record: record(), spawnToken: 'token-1' }) - - expect(launch.env.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') - }) - - it('still strips it once a managed account owns the credential', async () => { - const launch = await createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/repos/workspace-1', - resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' } - })({ record: record(), spawnToken: 'token-1' }) - - expect(launch.env.ANTHROPIC_API_KEY).toBeUndefined() - }) - - it('refuses a configured override of a pinned managed account, as the terminal path does', async () => { - await expect( - createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => '/repos/workspace-1', - resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnv: () => ({ ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' }), - inheritedEnv: {} - })({ record: record(), spawnToken: 'token-1' }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) - }) -}) diff --git a/src/main/claude/claude-tui-resume-launch.ts b/src/main/claude/claude-tui-resume-launch.ts deleted file mode 100644 index 8c09335fd9d2..000000000000 --- a/src/main/claude/claude-tui-resume-launch.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import { resolveClaudeCommand } from '../codex-cli/command' -import { getSpawnArgsForWindows } from '../win32-utils' -import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - claudeAuthEnvCarriedForward, - hasClaudeAuthEnvConflict -} from '../claude-accounts/environment' -import { buildClaudeChildProcessEnv } from './claude-child-process-environment' -import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' -import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' -import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' - -export const CLAUDE_TUI_RESUME_BASE_ARGS = [ - '--setting-sources', - CLAUDE_DEFAULT_SETTING_SOURCES.join(',') -] as const - -export type ClaudeTuiResumeLaunch = { - command: string - args: string[] - cwd: string - env: Record - providerSessionId: string - resumeLeafUuid: string | null -} - -export type ClaudeTuiResumeLaunchBuilderDeps = { - resolveWorkspacePath: (workspaceId: string) => Promise - resolveCommand?: () => string - resolveEnv?: () => Record - inheritedEnv?: NodeJS.ProcessEnv - /** - * Required so whoever wires this module up has to answer the question rather than - * inherit the wrong default: buildClaudeChildProcessEnv strips its inherited half - * unconditionally, which would sign out a system-auth user whose own ANTHROPIC_* - * is their only credential. Build it with claudeStructuredAuthPolicyForSettings. - */ - resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy -} - -export function createClaudeTuiResumeLaunchBuilder( - deps: ClaudeTuiResumeLaunchBuilderDeps -): (input: { record: AgentSessionRecord; spawnToken: string }) => Promise { - return async ({ record, spawnToken }) => { - if (record.provider !== 'claude') { - throw new Error(`session ${record.sessionId} is a ${record.provider} session`) - } - if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { - throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) - } - const head = agentSessionProviderHandleChainHead(record.providerHandleChain) - if (head?.handle.provider !== 'claude') { - throw new Error('claude_tui_resume_handle_required') - } - - const command = (deps.resolveCommand ?? resolveClaudeCommand)() - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ - ...(record.launchArgs ?? []), - ...CLAUDE_TUI_RESUME_BASE_ARGS, - '--resume', - head.handle.sessionId - ]) - const auth = await deps.resolveAuthPolicy() - const configuredEnv = deps.resolveEnv?.() ?? {} - if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(configuredEnv)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) - } - // The inherited half is always stripped downstream, so a system-auth user's own - // credential only reaches the resumed TUI if it is carried in the configured half. - const carriedAuth = auth.stripAuthEnv - ? {} - : claudeAuthEnvCarriedForward(deps.inheritedEnv ?? process.env) - // Compared against what the child would otherwise inherit, so the record's account - // home still wins over a diverging overlay without a needless pin. - const inheritedEnv = { ...(deps.inheritedEnv ?? process.env), ...configuredEnv } - const env = buildClaudeChildProcessEnv( - { - ...carriedAuth, - ...configuredEnv, - ...claudeConfigDirEnvPatch(record.accountHome.path, { env: inheritedEnv }), - ORCA_AGENT_LAUNCH_TOKEN: spawnToken, - [CLAUDE_SPAWN_TOKEN_ENV]: spawnToken - }, - { inheritedEnv: deps.inheritedEnv } - ) - const pairedEnv = withCliRuntimeOnPath(command, env, { platform: process.platform }) - - return { - command: spawnCmd, - args: spawnArgs, - cwd: await deps.resolveWorkspacePath(record.location.workspaceId), - env: pairedEnv, - providerSessionId: head.handle.sessionId, - resumeLeafUuid: head.handle.leafUuid - } - } -} diff --git a/src/main/claude/claude-tui-resume-proof.test.ts b/src/main/claude/claude-tui-resume-proof.test.ts deleted file mode 100644 index 6d2e99d4b006..000000000000 --- a/src/main/claude/claude-tui-resume-proof.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { proveClaudeTuiResume, readClaudeTuiSessionStartEvidence } from './claude-tui-resume-proof' - -const SESSION = '91deba8d-a398-4b69-a05d-35041536fe8e' -const TRANSCRIPT = '/accounts/claude/projects/workspace/transcript.jsonl' - -function envelope(overrides: Record = {}): Record { - return { - launchToken: 'spawn-one', - payload: JSON.stringify({ - hook_event_name: 'SessionStart', - source: 'resume', - session_id: SESSION, - transcript_path: TRANSCRIPT, - ...overrides - }) - } -} - -describe('Claude TUI resume proof', () => { - it('reads SessionStart identity from the hook envelope', () => { - expect(readClaudeTuiSessionStartEvidence(envelope())).toEqual({ - hookEventName: 'SessionStart', - source: 'resume', - sessionId: SESSION, - transcriptPath: TRANSCRIPT, - launchToken: 'spawn-one' - }) - }) - - it('proves the exact launched session and transcript without terminal output', async () => { - await expect( - proveClaudeTuiResume({ - expectedSessionId: SESSION, - expectedTranscriptPath: TRANSCRIPT, - expectedLaunchToken: 'spawn-one', - waitForSessionStart: async () => envelope() - }) - ).resolves.toMatchObject({ sessionId: SESSION, transcriptPath: TRANSCRIPT }) - }) - - it.each([ - ['source', { source: 'startup' }, /resume SessionStart/], - ['session', { session_id: 'other-session' }, /different Claude session/], - ['transcript', { transcript_path: '/other/transcript.jsonl' }, /different Claude transcript/] - ])('rejects a mismatched %s', async (_name, overrides, expected) => { - await expect( - proveClaudeTuiResume({ - expectedSessionId: SESSION, - expectedTranscriptPath: TRANSCRIPT, - expectedLaunchToken: 'spawn-one', - waitForSessionStart: async () => envelope(overrides) - }) - ).rejects.toThrow(expected) - }) - - it('rejects a SessionStart from another launched process', async () => { - await expect( - proveClaudeTuiResume({ - expectedSessionId: SESSION, - expectedTranscriptPath: TRANSCRIPT, - expectedLaunchToken: 'spawn-two', - waitForSessionStart: async () => envelope() - }) - ).rejects.toThrow(/different launched process/) - }) - - it('compares Windows paths using host path semantics', async () => { - await expect( - proveClaudeTuiResume({ - expectedSessionId: SESSION, - expectedTranscriptPath: 'C:\\Users\\Dev\\session.jsonl', - expectedLaunchToken: 'spawn-one', - platform: 'win32', - waitForSessionStart: async () => - envelope({ transcript_path: 'c:\\users\\dev\\session.jsonl' }) - }) - ).resolves.toMatchObject({ sessionId: SESSION }) - }) -}) diff --git a/src/main/claude/claude-tui-resume-proof.ts b/src/main/claude/claude-tui-resume-proof.ts deleted file mode 100644 index f352423116e7..000000000000 --- a/src/main/claude/claude-tui-resume-proof.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { posix, win32 } from 'node:path' - -export type ClaudeTuiSessionStartEvidence = { - hookEventName: 'SessionStart' - source: 'resume' - sessionId: string - transcriptPath: string - launchToken: string -} - -function record(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : null -} - -function nonEmptyString(value: unknown): string | null { - return typeof value === 'string' && value.length > 0 ? value : null -} - -function hookPayload(envelope: Record): Record | null { - if (typeof envelope.payload === 'string') { - try { - return record(JSON.parse(envelope.payload)) - } catch { - return null - } - } - return record(envelope.payload) ?? envelope -} - -export function readClaudeTuiSessionStartEvidence( - value: unknown -): ClaudeTuiSessionStartEvidence | null { - const envelope = record(value) - if (!envelope) { - return null - } - const payload = hookPayload(envelope) - if (!payload) { - return null - } - const hookEventName = nonEmptyString(payload.hook_event_name ?? payload.hookEventName) - const source = nonEmptyString(payload.source) - const sessionId = nonEmptyString(payload.session_id ?? payload.sessionId) - const transcriptPath = nonEmptyString(payload.transcript_path ?? payload.transcriptPath) - const launchToken = nonEmptyString(envelope.launchToken ?? payload.launchToken) - return hookEventName === 'SessionStart' && - source === 'resume' && - sessionId && - transcriptPath && - launchToken - ? { hookEventName, source, sessionId, transcriptPath, launchToken } - : null -} - -function comparablePath(value: string, platform: NodeJS.Platform): string | null { - if (value.includes('\0')) { - return null - } - const path = platform === 'win32' ? win32 : posix - if (!path.isAbsolute(value)) { - return null - } - const normalized = path.normalize(value) - return platform === 'win32' ? normalized.toLowerCase() : normalized -} - -export async function proveClaudeTuiResume(input: { - expectedSessionId: string - expectedTranscriptPath: string - expectedLaunchToken: string - waitForSessionStart: () => Promise - timeoutMs?: number - platform?: NodeJS.Platform -}): Promise { - const timeoutMs = input.timeoutMs ?? 15_000 - let timer: ReturnType | undefined - try { - const evidence = readClaudeTuiSessionStartEvidence( - await Promise.race([ - input.waitForSessionStart(), - new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new Error('The agent terminal did not prove the expected Claude resume.')), - timeoutMs - ) - timer.unref?.() - }) - ]) - ) - if (!evidence) { - throw new Error('The agent terminal did not emit a Claude resume SessionStart proof.') - } - if (evidence.launchToken !== input.expectedLaunchToken) { - throw new Error('The Claude resume proof came from a different launched process.') - } - if (evidence.sessionId !== input.expectedSessionId) { - throw new Error('The agent terminal resumed a different Claude session.') - } - const platform = input.platform ?? process.platform - const expectedPath = comparablePath(input.expectedTranscriptPath, platform) - const observedPath = comparablePath(evidence.transcriptPath, platform) - if (!expectedPath || !observedPath || observedPath !== expectedPath) { - throw new Error('The agent terminal resumed a different Claude transcript.') - } - return evidence - } finally { - clearTimeout(timer) - } -} diff --git a/src/main/claude/claude-tui-resume-real-binary.integration.test.ts b/src/main/claude/claude-tui-resume-real-binary.integration.test.ts deleted file mode 100644 index ff525f640237..000000000000 --- a/src/main/claude/claude-tui-resume-real-binary.integration.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' -import * as pty from 'node-pty' -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { resolveClaudeCommand } from '../codex-cli/command' -import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' -import { getSpawnArgsForWindows } from '../win32-utils' -import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' -import { - ClaudeStructuredSessionAdapter, - type ClaudeStructuredSessionEvent -} from './claude-structured-session-adapter' -import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch' -import { proveClaudeTuiResume } from './claude-tui-resume-proof' - -const command = resolveClaudeCommand() -const claudeAvailable = - spawnSync(command, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0 -const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json']) -const claudeAuthenticated = (() => { - if (!claudeAvailable) { - return false - } - const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, { - encoding: 'utf8', - windowsHide: true, - timeout: 5_000 - }) - return result.status === 0 && /"loggedIn"\s*:\s*true/.test(result.stdout) -})() -const roots: string[] = [] -const transcripts: string[] = [] - -function shellQuote(value: string): string { - return process.platform === 'win32' - ? `"${value.replace(/"/g, '""')}"` - : `'${value.replace(/'/g, `'"'"'`)}'` -} - -async function installCaptureHook( - root: string -): Promise<{ eventsPath: string; settingsPath: string }> { - const scriptPath = join(root, 'capture-session-start.cjs') - const eventsPath = join(root, 'session-start.jsonl') - const settingsPath = join(root, 'settings.json') - await writeFile( - scriptPath, - [ - "const { appendFileSync } = require('node:fs')", - "let input = ''", - "process.stdin.setEncoding('utf8')", - "process.stdin.on('data', (chunk) => { input += chunk })", - "process.stdin.on('end', () => {", - ' const payload = JSON.parse(input)', - ' payload.launchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN', - ' appendFileSync(process.argv[2], `${JSON.stringify(payload)}\\n`)', - '})', - '' - ].join('\n') - ) - await writeFile( - settingsPath, - JSON.stringify({ - theme: 'dark', - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: [process.execPath, scriptPath, eventsPath].map(shellQuote).join(' ') - } - ] - } - ] - } - }) - ) - return { eventsPath, settingsPath } -} - -async function waitForHook( - eventsPath: string, - source: 'startup' | 'resume' -): Promise> { - const deadline = Date.now() + 15_000 - while (Date.now() < deadline) { - const contents = await readFile(eventsPath, 'utf8').catch(() => '') - for (const line of contents.split(/\r?\n/)) { - if (!line.trim()) { - continue - } - const event = JSON.parse(line) as Record - if (event.hook_event_name === 'SessionStart' && event.source === source) { - return event - } - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - throw new Error(`Claude did not emit a ${source} SessionStart hook`) -} - -type RunningTui = { proc: pty.IPty; exited: Promise } - -function spawnResumeTui(args: string[], env: Record): RunningTui { - const direct = process.platform === 'win32' - const proc = pty.spawn( - direct ? command : process.env.SHELL || '/bin/zsh', - direct ? args : ['-l'], - { - name: 'xterm-256color', - cols: 100, - rows: 30, - cwd: process.cwd(), - env: { ...env, TERM: 'xterm-256color' } - } - ) - if (!direct) { - setTimeout(() => { - proc.write(`${[command, ...args].map(shellQuote).join(' ')}\r`) - }, 100).unref() - } - return { proc, exited: new Promise((resolve) => proc.onExit(() => resolve())) } -} - -function structuredIdentity(providerSessionId: string): AgentSessionJournalIdentity { - return { - sessionId: 'orca-real-claude-resume', - workspaceId: 'workspace-real', - hostId: 'local', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } - } -} - -async function waitForStructuredResult(events: ClaudeStructuredSessionEvent[]): Promise { - const deadline = Date.now() + 30_000 - while (Date.now() < deadline) { - if (events.some((event) => event.type === 'message' && event.message.type === 'result')) { - return - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - throw new Error('Claude structured session did not finish its product-path turn') -} - -async function stopTui(tui: RunningTui): Promise { - try { - tui.proc.kill('SIGKILL') - } catch { - return - } - await Promise.race([ - tui.exited, - new Promise((_resolve, reject) => - setTimeout(() => reject(new Error('Claude TUI did not exit after cleanup')), 5_000) - ) - ]) -} - -afterEach(async () => { - await Promise.all(transcripts.splice(0).map((path) => rm(path, { force: true }))) - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => { - it('resumes a product-created structured session and proves its exact child', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-resume-')) - roots.push(root) - const { eventsPath, settingsPath } = await installCaptureHook(root) - const providerSessionId = randomUUID() - const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') - const events: ClaudeStructuredSessionEvent[] = [] - const settlements: { clientMessageId: string }[] = [] - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: command, - options: { - ...CLAUDE_STRUCTURED_BASE_OPTIONS, - extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: settingsPath }, - sessionId: providerSessionId - }, - cwd: process.cwd(), - claudeConfigDir, - providerSessionId, - resumeLeafUuid: null, - resumesTranscript: false, - continuesChain: false - }), - onEvent: (event) => events.push(event), - onDispatchSettledLate: (settlement) => settlements.push(settlement), - readProcessStartTime: async () => 1 - }) - let resumed: RunningTui | null = null - try { - const acquisition = await adapter.acquire({ - identity: structuredIdentity(providerSessionId), - fence: 1, - spawnToken: 'real-create' - }) - await expect( - adapter.dispatch({ - sessionId: 'orca-real-claude-resume', - clientMessageId: 'real-product-turn', - fence: 1, - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'Reply only with ORCA_RESUME_READY.' }] - } - }) - ).resolves.toEqual({ state: 'admitted' }) - await waitForStructuredResult(events) - // The real CLI's replay is what settles the send; dispatch only admitted it. - expect(settlements.map((settlement) => settlement.clientMessageId)).toContain( - 'real-product-turn' - ) - const started = await waitForHook(eventsPath, 'startup') - const transcriptPath = String(started.transcript_path) - transcripts.push(transcriptPath) - expect(started.session_id).toBe(providerSessionId) - await adapter.closeAll() - - const record = { - sessionId: 'orca-real-claude-resume', - provider: 'claude', - location: { workspaceId: 'workspace-real' }, - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: claudeConfigDir }, - providerHandleChain: [ - { - linkId: 'created-real', - handle: acquisition.link.handle, - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ] - } as AgentSessionRecord - const launch = await createClaudeTuiResumeLaunchBuilder({ - resolveWorkspacePath: async () => process.cwd(), - resolveCommand: () => command, - // The real binary authenticates from the developer's own environment here, - // which is the system-auth case: stripping it would sign the resume out. - resolveAuthPolicy: () => ({ stripAuthEnv: false }) - })({ record, spawnToken: 'real-resume' }) - resumed = spawnResumeTui([...launch.args, '--settings', settingsPath], launch.env) - let resumedOutput = '' - resumed.proc.onData((data) => { - resumedOutput = `${resumedOutput}${data}`.slice(-4_000) - }) - - const [processIdentity, proof] = await Promise.all([ - readStructuredTuiProcessIdentity({ - hostId: 'local', - rootPid: resumed.proc.pid, - spawnToken: 'real-resume', - agent: 'claude' - }), - proveClaudeTuiResume({ - expectedSessionId: providerSessionId, - expectedTranscriptPath: transcriptPath, - expectedLaunchToken: 'real-resume', - waitForSessionStart: () => waitForHook(eventsPath, 'resume') - }).catch((error) => { - throw new Error(`${String(error)}\nClaude output: ${resumedOutput}`) - }) - ]) - expect(processIdentity).toMatchObject({ - hostId: 'local', - spawnToken: 'real-resume', - pid: expect.any(Number) - }) - expect(proof).toMatchObject({ sessionId: providerSessionId, transcriptPath }) - } finally { - await adapter.closeAll() - if (resumed) { - await stopTui(resumed) - } - } - }, 30_000) -}) diff --git a/src/main/codex/codex-resume-process-proof.test.ts b/src/main/codex/codex-resume-process-proof.test.ts deleted file mode 100644 index a46e01551eee..000000000000 --- a/src/main/codex/codex-resume-process-proof.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isCodexResumeProcessCommandLine, - readCodexResumeProcessIdentity -} from './codex-resume-process-proof' - -const THREAD_ID = '01a03a0d-acbd-74e0-86f2-2615984d3b37' - -describe('Codex resume process proof', () => { - it('binds the exact resumed thread while ignoring a generic Codex sibling', async () => { - await expect( - readCodexResumeProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-1', - threadId: THREAD_ID, - platform: 'darwin', - readPosixRows: async () => [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - { - pid: 101, - ppid: 100, - stat: 'S+', - command: `node /opt/codex/bin/codex resume ${THREAD_ID}` - }, - { - pid: 103, - ppid: 101, - stat: 'S+', - command: `/opt/codex/vendor/codex resume ${THREAD_ID}` - }, - { - pid: 102, - ppid: 100, - stat: 'S+', - command: `node /opt/codex/bin/codex --profile work resume ${THREAD_ID}` - } - ], - excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }], - readStartTime: async () => 1_700_000_000_000, - timeoutMs: 0 - }) - ).resolves.toMatchObject({ pid: 102 }) - }) - - it('rejects the previous owner process tree when no new resume child appears', async () => { - await expect( - readCodexResumeProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-1', - threadId: THREAD_ID, - platform: 'darwin', - readPosixRows: async () => [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex' }, - { - pid: 102, - ppid: 101, - stat: 'S+', - command: `/opt/codex/vendor/codex resume ${THREAD_ID}` - } - ], - excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }], - timeoutMs: 0 - }) - ).rejects.toThrow('one exact Codex child process') - }) - - it.each([ - ['another thread', `node /opt/codex/bin/codex resume thread-other`], - ['a generic Codex child', 'node /opt/codex/bin/codex --profile work'] - ])('rejects %s', async (_case, command) => { - await expect( - readCodexResumeProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-1', - threadId: THREAD_ID, - platform: 'darwin', - readPosixRows: async () => [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - { pid: 101, ppid: 100, stat: 'S+', command } - ], - timeoutMs: 0 - }) - ).rejects.toThrow('one exact Codex child process') - }) - - it('accepts an exact resume process after the previous PID was recycled', async () => { - await expect( - readCodexResumeProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-1', - threadId: THREAD_ID, - platform: 'darwin', - readPosixRows: async () => [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - { - pid: 101, - ppid: 100, - stat: 'S+', - command: `node /opt/codex/bin/codex resume ${THREAD_ID}` - } - ], - excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: 10 }], - readStartTime: async () => 5_000, - timeoutMs: 0 - }) - ).resolves.toMatchObject({ pid: 101 }) - }) - - it('parses a quoted Windows executable path with the exact resume argv', () => { - expect( - isCodexResumeProcessCommandLine( - `"C:\\Program Files\\Codex\\codex.exe" --profile work resume ${THREAD_ID}`, - THREAD_ID, - 'win32' - ) - ).toBe(true) - }) -}) diff --git a/src/main/codex/codex-resume-process-proof.ts b/src/main/codex/codex-resume-process-proof.ts deleted file mode 100644 index 1c6839bf8679..000000000000 --- a/src/main/codex/codex-resume-process-proof.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt' -import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' -import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' - -const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024 - -function tokenizeWindowsProcessCommandLine(commandLine: string): string[] { - const tokens: string[] = [] - let current = '' - let quoted = false - let started = false - let index = 0 - while (index < commandLine.length) { - const char = commandLine[index]! - if (!started && /\s/.test(char)) { - index += 1 - continue - } - started = true - if (char === '\\') { - let backslashes = 0 - while (commandLine[index] === '\\') { - backslashes += 1 - index += 1 - } - if (commandLine[index] === '"') { - current += '\\'.repeat(Math.floor(backslashes / 2)) - if (backslashes % 2 === 1) { - current += '"' - index += 1 - } - } else { - current += '\\'.repeat(backslashes) - } - continue - } - if (char === '"') { - if (quoted && commandLine[index + 1] === '"') { - current += '"' - index += 2 - continue - } - quoted = !quoted - index += 1 - continue - } - if (!quoted && /\s/.test(char)) { - tokens.push(current) - current = '' - started = false - index += 1 - continue - } - current += char - index += 1 - } - if (started) { - tokens.push(current) - } - return tokens -} - -function tokenizeProcessCommandLine( - commandLine: string, - platform: NodeJS.Platform -): string[] | null { - if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) { - return null - } - if (platform === 'win32') { - return tokenizeWindowsProcessCommandLine(commandLine) - } - const parsed = tokenizeCustomCommandTemplate(commandLine) - return parsed.ok ? parsed.tokens : null -} - -export function isCodexResumeProcessCommandLine( - commandLine: string, - threadId: string, - platform: NodeJS.Platform = process.platform -): boolean { - const tokens = tokenizeProcessCommandLine(commandLine, platform) - if (!tokens || !threadId) { - return false - } - return tokens.some((token, index) => token === 'resume' && tokens[index + 1] === threadId) -} - -type StructuredTuiIdentityInput = Parameters[0] - -export function readCodexResumeProcessIdentity( - input: Omit & { threadId: string } -): Promise { - const { threadId, ...identityInput } = input - const platform = input.platform ?? process.platform - return readStructuredTuiProcessIdentity({ - ...identityInput, - agent: 'codex', - processCommandMatches: (command) => isCodexResumeProcessCommandLine(command, threadId, platform) - }) -} diff --git a/src/main/codex/codex-tui-resume-real-binary.integration.test.ts b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts deleted file mode 100644 index cada4249591f..000000000000 --- a/src/main/codex/codex-tui-resume-real-binary.integration.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import * as pty from 'node-pty' -import { afterEach, describe, expect, it } from 'vitest' -import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' -import { resolveCodexCommand } from '../codex-cli/command' -import { openCodexAppServerConnection } from './codex-app-server-connection' -import { openCodexThread } from './codex-structured-thread-open' -import { proveCodexTuiRollout } from './codex-tui-rollout-proof' - -const codexCommand = resolveCodexCommand() -const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0 -const itWithCodex = codexAvailable ? it : it.skip -const tempHomes: string[] = [] - -async function waitForTuiStart(proc: pty.IPty): Promise { - let output = '' - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)), - 15_000 - ) - proc.onData((data) => { - output += data - if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) { - clearTimeout(timeout) - resolve(output) - } - }) - proc.onExit(({ exitCode }) => { - clearTimeout(timeout) - reject( - new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`) - ) - }) - }) -} - -async function waitForRollout(path: string, threadId: string): Promise { - const deadline = Date.now() + 5_000 - while (Date.now() < deadline) { - try { - const rollout = await readFile(path, 'utf8') - if (rollout.includes(threadId)) { - return rollout - } - } catch { - // The rollout is created asynchronously after thread/start. - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - throw new Error('Codex did not materialize the resumed rollout') -} - -afterEach(async () => { - await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) -}) - -describe('real Codex structured-to-TUI resume', () => { - itWithCodex( - 'resumes the exact isolated rollout and reaches the initial TUI screen', - async () => { - const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-')) - tempHomes.push(codexHome) - await writeFile( - join(codexHome, 'config.toml'), - [ - 'model_provider = "orca-integration"', - 'model = "gpt-5"', - '', - '[model_providers.orca-integration]', - 'name = "Orca integration"', - 'base_url = "http://127.0.0.1:9/v1"', - 'wire_api = "responses"', - 'requires_openai_auth = false', - '', - `[projects.${JSON.stringify(process.cwd())}]`, - 'trust_level = "trusted"', - '' - ].join('\n') - ) - const connection = await openCodexAppServerConnection({ - command: codexCommand, - args: ['app-server'], - env: { CODEX_HOME: codexHome } - }) - const opened = await openCodexThread( - connection, - { cwd: process.cwd(), resumeThreadId: null }, - 15_000 - ) - await connection.request( - 'turn/start', - { - threadId: opened.threadId, - clientUserMessageId: 'real-binary-resume-fixture', - input: [{ type: 'text', text: 'materialize the isolated resume fixture' }] - }, - { timeoutMs: 15_000 } - ) - expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId) - await connection.close() - - expect(opened.historyPath).toContain(opened.threadId) - expect(opened.historyPath).toContain(join(codexHome, 'sessions')) - const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], { - name: 'xterm-256color', - cols: 100, - rows: 30, - cwd: process.cwd(), - env: { - ...process.env, - CODEX_HOME: codexHome, - ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof', - TERM: 'xterm-256color' - } - }) - const tuiExit = new Promise((resolve) => - tui.onExit(({ exitCode }) => resolve(exitCode)) - ) - const kittyKeyboard = new TerminalKittyKeyboardModeTracker() - let tuiOutput = '' - let lastOutputAt: number | null = null - tui.onData((data) => { - tuiOutput += data - lastOutputAt = Date.now() - kittyKeyboard.scan(data) - }) - try { - await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i) - const proof = await proveCodexTuiRollout({ - codexHome, - threadId: opened.threadId, - kittyKeyboardFlags: kittyKeyboard.flags, - readOutput: () => ({ text: tuiOutput, lastOutputAt }), - write: (data) => { - tui.write(data) - return true - } - }) - expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!)) - } finally { - try { - tui.kill() - } catch { - // Already exited. - } - await Promise.race([ - tuiExit, - new Promise((_resolve, reject) => - setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000) - ) - ]) - } - }, - 30_000 - ) -}) diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-tui-rollout-proof.test.ts index 2de66c0dc71e..130d167f21e6 100644 --- a/src/main/codex/codex-tui-rollout-proof.test.ts +++ b/src/main/codex/codex-tui-rollout-proof.test.ts @@ -2,45 +2,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { - codexTuiStatusProbeInput, - parseCodexTuiStatusSessionId, - proveCodexTuiRollout, - resolveLiveCodexTuiRollout, - resolvePinnedCodexRolloutProof -} from './codex-tui-rollout-proof' +import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70' const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71' -describe('Codex TUI rollout proof', () => { - it('parses the exact session shown by status', () => { - expect(parseCodexTuiStatusSessionId(`│ Session: ${THREAD} │`)).toBe(THREAD) - expect(parseCodexTuiStatusSessionId(`Session ID: ${THREAD}`)).toBe(THREAD) - expect(parseCodexTuiStatusSessionId(`Session: \u001b[22m${THREAD}\u001b[2m`)).toBe(THREAD) - expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull() - }) - - it('parses the Codex 0.148 status screen and semantic thread labels', () => { - const status = [ - '\u001b[2m│ >_ OpenAI Codex (v0.148.0) │', - '│ Model: gpt-5.6-sol (reasoning high, summaries auto) │', - `│ Session: \u001b[22m${THREAD}\u001b[2m │` - ].join('\n') - - expect(parseCodexTuiStatusSessionId(status)).toBe(THREAD) - expect(parseCodexTuiStatusSessionId(`Thread ID: ${OTHER_THREAD}`)).toBe(OTHER_THREAD) - }) - - it('uses Kitty Enter only while the TUI negotiated Kitty input', () => { - expect(codexTuiStatusProbeInput(0)).toEqual({ - command: '\u001b[200~/status\u001b[201~', - submit: '\r' - }) - expect(codexTuiStatusProbeInput(1).submit).toBe('\u001b[13u') - expect(codexTuiStatusProbeInput(31).submit).toBe('\u001b[13u') - }) - +describe('Codex pinned rollout proof', () => { it('resolves only the exact session_meta rollout under the pinned account home', async () => { const files = async function* (): AsyncGenerator { yield '/pinned/sessions/scratch/rollout-wrong.jsonl' @@ -103,103 +70,4 @@ describe('Codex TUI rollout proof', () => { }) ).resolves.toBeNull() }) - - it('rejects a different status session after filesystem proof', async () => { - let reads = 0 - const write = vi.fn((_data: string) => true) - await expect( - proveCodexTuiRollout({ - codexHome: '/pinned', - threadId: THREAD, - kittyKeyboardFlags: 0, - readOutput: () => ({ - text: `Session: ${OTHER_THREAD}`, - lastOutputAt: reads++ > 0 ? 2 : 1 - }), - write, - resolveRollout: async () => '/pinned/sessions/rollout.jsonl', - delay: async () => undefined - }) - ).rejects.toThrow('resumed a different Codex session') - expect(write).toHaveBeenCalledTimes(2) - }) - - it('dismisses status only after the exact session is observed', async () => { - let reads = 0 - const write = vi.fn((_data: string) => true) - await expect( - proveCodexTuiRollout({ - codexHome: '/pinned', - threadId: THREAD, - kittyKeyboardFlags: 1, - readOutput: () => ({ - text: reads++ > 0 ? `Session: ${THREAD}` : '', - lastOutputAt: reads > 1 ? 2 : 1 - }), - write, - resolveRollout: async () => '/pinned/sessions/rollout.jsonl', - delay: async () => undefined - }) - ).resolves.toEqual({ transcriptPath: '/pinned/sessions/rollout.jsonl' }) - expect(write.mock.calls.map(([data]) => data)).toEqual([ - '\u001b[200~/status\u001b[201~', - '\u001b[13u', - '\u001b' - ]) - }) - - it('discovers the live thread and resolves its pinned rollout', async () => { - let reads = 0 - const write = vi.fn((_data: string) => true) - const resolveRollout = vi.fn(async () => '/pinned/sessions/rollout.jsonl') - - await expect( - resolveLiveCodexTuiRollout({ - codexHome: '/pinned', - kittyKeyboardFlags: 0, - readOutput: () => ({ - text: reads++ > 0 ? `Session: ${THREAD}` : '', - lastOutputAt: reads > 1 ? 2 : 1 - }), - write, - resolveRollout, - delay: async () => undefined - }) - ).resolves.toEqual({ threadId: THREAD, transcriptPath: '/pinned/sessions/rollout.jsonl' }) - expect(resolveRollout).toHaveBeenCalledWith('/pinned', THREAD) - expect(write.mock.calls.map(([data]) => data)).toEqual([ - '\u001b[200~/status\u001b[201~', - '\r', - '\u001b' - ]) - }) - - it('accepts a proven blank Codex 0.148 session before its lazy rollout exists', async () => { - let reads = 0 - const write = vi.fn((_data: string) => true) - const resolveRollout = vi.fn(async () => null) - - await expect( - resolveLiveCodexTuiRollout({ - codexHome: '/pinned', - kittyKeyboardFlags: 1, - readOutput: () => ({ - text: - reads++ > 0 - ? `│ >_ OpenAI Codex (v0.148.0) │\n│ Session: \u001b[22m${THREAD}\u001b[2m │` - : '', - lastOutputAt: reads > 1 ? 2 : 1 - }), - write, - resolveRollout, - delay: async () => undefined - }) - ).resolves.toEqual({ threadId: THREAD }) - expect(resolveRollout).toHaveBeenCalledTimes(5) - expect(write.mock.calls.map(([data]) => data)).toEqual([ - '\u001b[200~/status\u001b[201~', - '\u001b[13u', - '\u001b' - ]) - }) }) diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts index 0e9293f1616b..dfab50cae08f 100644 --- a/src/main/codex/codex-tui-rollout-proof.ts +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -1,50 +1,13 @@ import { join } from 'node:path' -import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' import { relativePathInsideRoot } from '../../shared/cross-platform-path' import { readCodexRolloutSessionMetaId } from './codex-rollout-session-meta' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' -const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' -const STATUS_SESSION_RE = new RegExp( - `\\b(?:Session|Thread)(?:\\s+ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, - 'gi' -) -const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~' -const KITTY_ENTER = '\u001b[13u' -const TAB = '\t' - -export type CodexTuiProofOutput = { - text: string - lastOutputAt: number | null -} - export type CodexTuiRolloutProofOptions = { listFiles?: (sessionsRoot: string) => AsyncIterable readSessionMetaId?: (filePath: string) => Promise } -export function parseCodexTuiStatusSessionId(output: string): string | null { - let sessionId: string | null = null - for (const match of stripAnsiEscapeSequences(output).matchAll(STATUS_SESSION_RE)) { - sessionId = match[1] ?? null - } - return sessionId -} - -export function codexTuiStatusSubmitInput(kittyKeyboardFlags: number): string { - return kittyKeyboardFlags > 0 ? KITTY_ENTER : '\r' -} - -export function codexTuiStatusProbeInput(kittyKeyboardFlags: number): { - command: string - submit: string -} { - return { - command: STATUS_COMMAND_PASTE, - submit: codexTuiStatusSubmitInput(kittyKeyboardFlags) - } -} - export async function resolvePinnedCodexRolloutProof( codexHome: string, threadId: string, @@ -80,126 +43,3 @@ export async function resolvePinnedCodexRolloutProof( } return null } - -export async function proveCodexTuiRollout(input: { - codexHome: string - threadId: string - kittyKeyboardFlags: number - readOutput: () => CodexTuiProofOutput - write: (data: string) => boolean - timeoutMs?: number - resolveRollout?: (codexHome: string, threadId: string) => Promise - delay?: (ms: number) => Promise -}): Promise<{ transcriptPath: string }> { - const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof - const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) - const proofDeadline = Date.now() + (input.timeoutMs ?? 15_000) - let transcriptPath: string | null = null - // Codex can rotate or finish flushing the rollout while the resumed TUI is - // starting. Keep the proof retryable inside the same bounded deadline. - while (!transcriptPath && Date.now() < proofDeadline) { - transcriptPath = await resolveRollout(input.codexHome, input.threadId) - if (!transcriptPath) { - await delay(Math.min(100, Math.max(1, proofDeadline - Date.now()))) - } - } - if (!transcriptPath) { - throw new Error('The agent terminal did not prove the expected Codex rollout.') - } - - const baselineOutputAt = input.readOutput().lastOutputAt - const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) - if (!input.write(probe.command)) { - throw new Error('The agent terminal could not verify its Codex session.') - } - await delay(100) - if (!input.write(probe.submit)) { - throw new Error('The agent terminal could not verify its Codex session.') - } - - const deadline = proofDeadline - const retrySubmitAt = Date.now() + 750 - let retriedSubmit = false - while (Date.now() < deadline) { - const output = input.readOutput() - if ( - !retriedSubmit && - Date.now() >= retrySubmitAt && - output.text.includes('/status') && - !parseCodexTuiStatusSessionId(output.text) - ) { - retriedSubmit = true - // Newer Codex builds keep the slash-command popup open after a bracketed - // paste. Tab commits the highlighted command as text; the following Enter - // then dispatches it instead of merely selecting the popup row. - if (!input.write(TAB)) { - throw new Error('The agent terminal could not finish Codex session verification.') - } - await delay(100) - if (!input.write(probe.submit)) { - throw new Error('The agent terminal could not finish Codex session verification.') - } - } - if (output.lastOutputAt !== baselineOutputAt) { - const observedThreadId = parseCodexTuiStatusSessionId(output.text) - if (observedThreadId && observedThreadId !== input.threadId) { - throw new Error('The agent terminal resumed a different Codex session.') - } - if (observedThreadId === input.threadId) { - if (!input.write('\u001b')) { - throw new Error('The agent terminal could not finish Codex session verification.') - } - return { transcriptPath } - } - } - await delay(100) - } - throw new Error('The agent terminal did not prove the expected Codex rollout.') -} - -export async function resolveLiveCodexTuiRollout(input: { - codexHome: string - kittyKeyboardFlags: number - readOutput: () => CodexTuiProofOutput - write: (data: string) => boolean - timeoutMs?: number - resolveRollout?: (codexHome: string, threadId: string) => Promise - delay?: (ms: number) => Promise -}): Promise<{ threadId: string; transcriptPath?: string }> { - const baselineOutputAt = input.readOutput().lastOutputAt - const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) - if (!input.write(probe.command)) { - throw new Error('The agent terminal could not verify its Codex session.') - } - const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) - await delay(100) - if (!input.write(probe.submit)) { - throw new Error('The agent terminal could not verify its Codex session.') - } - - const deadline = Date.now() + (input.timeoutMs ?? 15_000) - while (Date.now() < deadline) { - const output = input.readOutput() - if (output.lastOutputAt !== baselineOutputAt) { - const threadId = parseCodexTuiStatusSessionId(output.text) - if (threadId) { - const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof - let transcriptPath: string | null = null - // Codex 0.148 allocates a session before it writes a rollout; give a just-written - // file a short visibility window without rejecting a genuinely blank conversation. - for (let attempt = 0; attempt < 5 && !transcriptPath; attempt += 1) { - transcriptPath = await resolveRollout(input.codexHome, threadId) - if (!transcriptPath && attempt < 4) { - await delay(100) - } - } - if (!input.write('\u001b')) { - throw new Error('The agent terminal could not finish Codex session verification.') - } - return { threadId, ...(transcriptPath ? { transcriptPath } : {}) } - } - } - await delay(100) - } - throw new Error('The agent terminal did not publish a resumable Codex conversation.') -} diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 633f4c91b4c4..a5cb3ac5ff85 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -25,7 +25,6 @@ import { resizePtyFromRuntimeController, serializeProviderBufferFromRuntimeController, waitForRendererSerializerFromRuntimeController, - writePtyAgentSessionProofFromRuntimeController, writePtyFromRuntimeController } from './operations' import { supportsForegroundProcessEvidenceFromRuntimeController } from './foreground-process-evidence-capability' @@ -48,8 +47,6 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi write: (ptyId, data) => writePtyFromRuntimeController(deps, ptyId, data), writeWithSettlement: (ptyId, data) => writePtyFromRuntimeController(deps, ptyId, data, { waitForSettlement: true }), - writeAgentSessionProof: (ptyId, data, authority) => - writePtyAgentSessionProofFromRuntimeController(ptyId, data, authority), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 4bcc4f4e6432..c638743116a1 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -65,21 +65,6 @@ export function writePtyFromRuntimeController( } } -export function writePtyAgentSessionProofFromRuntimeController( - ptyId: string, - data: string, - authority: { sessionId: string; spawnToken: string } -): boolean { - if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { - return false - } - try { - return getProviderForPty(ptyId).write(ptyId, data) !== false - } catch { - return false - } -} - export async function probePtyLivenessFromRuntimeController( deps: PtyRuntimeControllerDeps, ptyId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index ef1cab100f80..4a8bdd7c0a0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -21,7 +21,6 @@ import type { AgentSessionExecutionLocation, AgentSessionLaunchArgs, AgentSessionLaunchEnv, - AgentSessionOwnerRuntimeKind, AgentSessionRecord } from '../../../shared/agent-session-record' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' @@ -61,7 +60,8 @@ export type AgentSessionAttachParams = { provider: AgentSessionHandleProvider agent: AgentSessionHandleProvider accountHome: AgentSessionAccountHome - runtimeKind: AgentSessionOwnerRuntimeKind + /** Always `native`; kept on the params because the operation fingerprint covers it. */ + runtimeKind: 'native' /** Host-resolved defaults for a create-by-intent; remote attach schemas do not accept them. */ options?: Readonly> /** The tab id a create reserves for this chat; absent records the id clients derive. Never on diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 6dfacbd2ff02..5d922d723503 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -19,9 +19,6 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly sessions: Map, private readonly subscribers: AgentSessionSubscribers, private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession, - private readonly handoffStatus: ( - sessionId: string - ) => Parameters[0]['handoff'], /** Task edges change the status summary too; the feed's equality check * keeps a no-op re-projection from reaching subscribers. */ private readonly onPublished: (sessionId: string) => void @@ -52,7 +49,6 @@ export class StructuredAgentSessionBackgroundTaskChannel { ...input, journal: session.journal, fence: this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0, - handoff: this.handoffStatus(input.sessionId), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts similarity index 57% rename from src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index df0fabf22fa0..5b56db5552e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -1,13 +1,9 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionHandoffDirection, - AgentSessionHandoffRequest, - AgentSessionMutationEnvelope -} from '../../../shared/agent-session-wire' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -18,10 +14,6 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -35,7 +27,6 @@ let acquire: Mock let activeModel: string let optionWritable: Promise let setOption: Mock -let transcriptPath: string function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { @@ -50,48 +41,6 @@ function envelope(method: string, fields: Record): AgentSession } } -function handoff(direction: AgentSessionHandoffDirection): AgentSessionHandoffRequest { - const fields = { direction, mode: 'now' as const, action: 'start' as const } - return { envelope: envelope('agentSession.requestHandoff', fields), ...fields } -} - -function owner(fence: number, spawnToken: string): StructuredTuiOwner { - return { - terminal: { - handle: 'term-claude', - tabId: 'tab-claude', - paneKey: 'pane-claude', - ptyId: 'pty-claude' - }, - process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `claude-tui-${fence}`, - handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'tui-leaf' }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - }, - transcriptPath - } -} - -function transport(): StructuredAgentSessionHandoffTransport { - return { - hostLabel: 'Test host', - launchTui: async ({ fence, spawnToken }) => owner(fence, spawnToken), - reproveTuiOwner: async ({ owner: current }) => current, - recoverTuiOwner: async (record) => - owner( - record.lease.runtimeFence, - record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' - ), - stopRecoveredOwner: async () => undefined, - waitForTuiExit: async (current) => ({ transcriptPath: current.transcriptPath }), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle' - } -} - function adapter(): StructuredAgentSessionAdapter { setOption = vi.fn(async ({ value }) => { activeModel = value @@ -126,12 +75,10 @@ function adapter(): StructuredAgentSessionAdapter { } beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-claude-handoff-options-')) + root = await mkdtemp(join(tmpdir(), 'orca-claude-options-')) resetHostTestOperationIds() activeModel = DEFAULT_MODEL optionWritable = Promise.resolve() - transcriptPath = join(root, 'claude.jsonl') - await writeFile(transcriptPath, '', 'utf8') store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, @@ -139,7 +86,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-claude', - handoffTransport: transport(), now: () => NOW }) expect( @@ -161,7 +107,7 @@ afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) }) -describe('Claude structured session handoff options', () => { +describe('Claude structured session options', () => { it('queues a pick made while the provider starts only once it can take it', async () => { let land = (): void => {} optionWritable = new Promise((resolve) => { @@ -179,29 +125,4 @@ describe('Claude structured session handoff options', () => { expect(await picked).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) }) - - it('keeps a directly selected model through chat to TUI to chat', async () => { - const fields = { key: 'model', value: PICKED_MODEL } - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - - expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true }) - await vi.waitFor( - async () => expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }), - { timeout: 10_000 } - ) - expect(await host.requestHandoff(CALLER, handoff('to-native'))).toMatchObject({ ok: true }) - await vi.waitFor( - async () => expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }), - { timeout: 10_000 } - ) - - expect(acquire.mock.calls[1]?.[0].options).toEqual({ model: PICKED_MODEL }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) - expect(activeModel).toBe(PICKED_MODEL) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index 705baf182fa4..b9b7021e854d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -75,25 +75,13 @@ it('delivers catalog changes through existing frames without resending them on o }) expect(state.commands).toEqual(commands) for (let i = 0; i < 25; i++) { - subscribers.handoff(sessionId, 7, { - owner: 'none', - direction: null, - phase: 'idle', - stage: null, - operationId: null - }) + subscribers.backgroundTasks(sessionId, null, 7) } coalescer.flush() expect(events.filter((event) => 'commands' in event)).toHaveLength(1) commands = [] subscribers.publish(sessionId, journal) - subscribers.handoff(sessionId, 7, { - owner: 'none', - direction: null, - phase: 'idle', - stage: null, - operationId: null - }) + subscribers.backgroundTasks(sessionId, null, 7) coalescer.flush() expect(state.commands).toEqual([]) expect(events.filter((event) => 'commands' in event)).toHaveLength(2) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts deleted file mode 100644 index 1255cc85ff38..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' -import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' - -export async function recoverDeadTuiHandoffStatus(input: { - store: AgentSessionRecordStore - now: () => number - record: AgentSessionRecord - expectedFence: number - probe: AgentSessionOwnerProbe -}): Promise { - const { expectedFence, now, probe, record, store } = input - if ( - record.lease.runtimeFence !== expectedFence || - record.lease.runtimeKind !== 'tui' || - record.lease.handoffStage !== null || - record.lease.claimStatus !== 'live' || - record.lease.ownerProcess === null - ) { - return null - } - const recovered = await recoverStoredDeadTuiOwnerForHandoff(store, { - sessionId: record.sessionId, - expectedFence, - operationId: createStructuredAgentSessionOperationId(randomUUID, now()), - probe, - now: now() - }) - return idleStructuredHandoffStatus(recovered) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index f8ba89072167..ab22f5c41d8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -60,10 +60,12 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe { // Quit's resume offer: what the sidebar shows, read while the child is still running. name: 'snapshot-before-stop', - run: (context) => { + run: async (context) => { if (context.hasProviderChild === false || !context.beforeProviderChildStop) { return } + // Events the provider already delivered are part of what the sidebar showed at the stop. + await context.eventSink.drained() try { context.beforeProviderChildStop() } catch { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts deleted file mode 100644 index b881d55e771e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffResult, - AgentSessionHandoffStatus, - AgentSessionMutationResult, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' -import { AGENT_SESSION_WIRE_REFUSAL_CODES } from '../../../shared/agent-session-wire' -import { - agentSessionFingerprintConflict, - computeAgentSessionPayloadFingerprint -} from '../../../shared/agent-session-mutation-envelope' -import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' -import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' - -export type StructuredHandoffAdmission = - | { decision: 'continue'; record: AgentSessionRecord; fingerprint: string } - | { decision: 'replay'; outcome: AgentSessionOperationOutcome } - | { decision: 'refused'; refusal: AgentSessionWireRefusal } - -export async function admitStructuredHandoffRequest(input: { - deps: StructuredAgentSessionHandoffDeps - operationGuard: StructuredAgentSessionHandoffOperationGuard - callerKey: string - params: AgentSessionHandoffRequest - record: AgentSessionRecord - status?: AgentSessionHandoffStatus -}): Promise { - const action = input.params.action ?? 'start' - const requestFingerprint = computeAgentSessionPayloadFingerprint({ - method: 'agentSession.requestHandoff', - sessionId: input.record.sessionId, - fields: { direction: input.params.direction, mode: input.params.mode, action } - }) - const conflict = agentSessionFingerprintConflict(input.params.envelope, requestFingerprint) - if (conflict) { - return { decision: 'refused', refusal: conflict } - } - const fingerprint = computeAgentSessionPayloadFingerprint({ - method: 'agentSession.requestHandoff.operation', - sessionId: input.record.sessionId, - fields: { direction: input.params.direction } - }) - const operation = await input.operationGuard.check({ - callerKey: input.callerKey, - sessionId: input.record.sessionId, - operationId: input.params.envelope.clientOperationId, - fingerprint, - action, - ...(input.status ? { status: input.status } : {}), - now: input.deps.now() - }) - if (operation.decision === 'replay') { - return { decision: 'replay', outcome: operation.outcome } - } - if (operation.decision === 'refused') { - return { - decision: 'refused', - refusal: { - code: operation.code as 'agent_session_operation_conflict', - message: 'This handoff operation could not be admitted.' - } - } - } - if (input.params.envelope.expectedRuntimeFence !== input.record.lease.runtimeFence) { - await input.deps.store.recordOperationOutcome({ - callerKey: input.callerKey, - operationId: input.params.envelope.clientOperationId, - outcome: { status: 'failed', code: 'agent_session_checkpoint_stale' } - }) - input.operationGuard.finish(input.record.sessionId, input.params.envelope.clientOperationId) - return { - decision: 'refused', - refusal: { - code: 'agent_session_checkpoint_stale', - message: 'The session owner changed before the handoff request arrived.', - currentFence: input.record.lease.runtimeFence - } - } - } - return { decision: 'continue', record: input.record, fingerprint } -} - -export function replayedStructuredHandoffRefusal( - outcome: AgentSessionOperationOutcome -): AgentSessionWireRefusal | null { - if ( - outcome.status !== 'failed' || - !AGENT_SESSION_WIRE_REFUSAL_CODES.includes( - outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] - ) - ) { - return null - } - return { - code: outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number], - message: 'This handoff request was previously refused.' - } -} - -export async function refuseAdmittedStructuredHandoff(input: { - deps: StructuredAgentSessionHandoffDeps - callerKey: string - params: AgentSessionHandoffRequest - refusal: AgentSessionWireRefusal -}): Promise> { - await input.deps.store.recordOperationOutcome({ - callerKey: input.callerKey, - operationId: input.params.envelope.clientOperationId, - outcome: { status: 'failed', code: input.refusal.code } - }) - return { ok: false, refusal: input.refusal } -} - -export function structuredHandoffRetryIsAdmissible( - status: AgentSessionHandoffStatus, - params: AgentSessionHandoffRequest -): boolean { - return ( - status.phase === 'failed' && - status.direction === params.direction && - status.operationId === params.envelope.clientOperationId && - status.error?.recoverableOwner !== 'none' - ) -} - -export function structuredHandoffRetryResumesStoppedOwner( - record: AgentSessionRecord, - params: AgentSessionHandoffRequest -): boolean { - return ( - record.lease.claimStatus === 'released' && - record.lease.handoffStage === 'old-owner-stopped' && - record.lease.handoffOperationId === params.envelope.clientOperationId - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts deleted file mode 100644 index 02bf6259e20f..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' -import { switchingStructuredHandoffStatus } from './structured-agent-session-handoff-status' -import type { - StructuredAgentSessionHandoffDeps, - StructuredAgentSessionHandoffFlowContext, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -export function createStructuredHandoffFlowContext(input: { - deps: StructuredAgentSessionHandoffDeps - owner: (sessionId: string) => StructuredTuiOwner | undefined - retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void - releaseOwner: (sessionId: string) => void - setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void - requireRecord: (sessionId: string) => AgentSessionRecord -}): StructuredAgentSessionHandoffFlowContext { - const publishStage: StructuredAgentSessionHandoffFlowContext['publishStage'] = ( - record, - direction - ) => { - input.setStatus( - record.sessionId, - switchingStructuredHandoffStatus(record, direction, input.deps.transport?.hostLabel) - ) - } - return { - ...input, - publishStage, - enterPreparing: async (record, operationId, direction) => { - const prepared = await setStoredAgentSessionHandoffStage(input.deps.store, { - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - stage: 'preparing', - handoffOperationId: operationId, - now: input.deps.now() - }) - publishStage(prepared, direction) - } - } -} - -export function requireStructuredHandoffRecord( - deps: StructuredAgentSessionHandoffDeps, - sessionId: string -): AgentSessionRecord { - const record = deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return record -} - -export async function markStructuredHandoffManualRecovery( - context: StructuredAgentSessionHandoffFlowContext, - sessionId: string, - _operationId: string -): Promise { - const record = context.requireRecord(sessionId) - await setStoredAgentSessionHandoffStage(context.deps.store, { - sessionId, - fence: record.lease.runtimeFence, - stage: 'manual-recovery', - // A live TUI is still recoverable without an active operation; other records retain the - // failed operation so native/manual proof retries remain idempotent. - handoffOperationId: - record.lease.runtimeKind === 'tui' && record.lease.claimStatus === 'live' - ? null - : _operationId, - now: context.deps.now() - }) -} - -export async function stopStructuredNativeTurn( - deps: StructuredAgentSessionHandoffDeps, - sessionId: string, - turnId: string -): Promise { - const record = deps.store.getRecord(sessionId) - if (!record || record.lease.runtimeKind !== 'native') { - return false - } - const session = deps.session(sessionId) - return (await deps.acquireNativeStop?.(sessionId, turnId, session.fence)) ?? false -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts deleted file mode 100644 index 286a0dca059c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' -import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' -import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-flow-runner-outcome-write-failure' -const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' -const OPERATION = `${NOW}-00000000000000000000000000000002` -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -const fields = { direction: 'to-native' as const, mode: 'now' as const, action: 'retry' as const } - -const params: AgentSessionHandoffRequest = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.requestHandoff', - sessionId: SESSION, - fields - }) - }, - ...fields -} - -/** A runner whose scheduled flow always rejects, so every case here exercises the failure path. */ -async function failingFlowRunner( - fail: (params: AgentSessionHandoffRequest, error: unknown) => void -): Promise<{ - runner: StructuredAgentSessionHandoffFlowRunner - store: AgentSessionRecordStore - root: string -}> { - const root = await mkdtemp(join(tmpdir(), 'orca-handoff-flow-runner-')) - roots.push(root) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: join(root, 'journal') - }) - const runner = new StructuredAgentSessionHandoffFlowRunner({ - deps: { - store, - claimKeyId: 'key-1', - session: () => ({ journal, fence: 1 }), - suspendNative: async () => ({ state: 'stopped' as const }), - acquireNative: async () => { - throw new Error('unused') - }, - importTuiHistory: async () => {}, - retryPendingSettlement: async () => true, - publish: () => {}, - schedule: async () => { - throw new Error('scheduling failed') - }, - now: () => NOW - }, - operationGuard: new StructuredAgentSessionHandoffOperationGuard(store), - flowContext: (): StructuredAgentSessionHandoffFlowContext => { - throw new Error('unreachable: scheduling rejects before the flow needs context') - }, - fail - }) - return { runner, store, root } -} - -describe('structured handoff flow runner outcome-write failure', () => { - it('still reports the flow failure when the failed-outcome ledger write throws', async () => { - const failures: unknown[] = [] - const { runner, store, root } = await failingFlowRunner( - (_params, error) => void failures.push(error) - ) - // Materialize the store file so its later disappearance reads as corruption, making every - // subsequent ledger write reject. - await store.admitOperation({ - callerKey: 'seed', - operationId: `${NOW}-00000000000000000000000000000009`, - fingerprint: 'seed', - now: NOW - }) - await rm(join(root, 'store'), { recursive: true, force: true }) - - runner.begin({ callerKey: 'client-1', params, turnId: null, fingerprint: 'fp' }) - await runner.drain() - - expect(failures).toHaveLength(1) - expect((failures[0] as Error).message).toBe('scheduling failed') - }) - - it('does not leak an unhandled rejection when the failure notification itself throws', async () => { - // The host's status publish threw exactly here once eviction had dropped the session. - const { runner } = await failingFlowRunner(() => { - throw new Error('agent_session_ownership_unknown') - }) - const leaked: unknown[] = [] - const observe = (reason: unknown): void => void leaked.push(reason) - process.on('unhandledRejection', observe) - try { - runner.begin({ callerKey: 'client-1', params, turnId: null, fingerprint: 'fp' }) - await runner.drain() - // Node reports an orphaned rejection on the tick after it settles. - await new Promise((resolve) => setImmediate(resolve)) - } finally { - process.off('unhandledRejection', observe) - } - - expect(leaked).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts deleted file mode 100644 index 4259fc617253..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { stopStructuredNativeTurn } from './structured-agent-session-handoff-flow-context' -import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' -import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' -import { assertScheduledStructuredHandoffIsAdmissible } from './structured-agent-session-handoff-revalidation' -import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' -import { structuredTuiStatus } from './structured-agent-session-handoff-status' -import type { - StructuredAgentSessionHandoffDeps, - StructuredAgentSessionHandoffFlowContext -} from './structured-agent-session-handoff-types' - -export class StructuredAgentSessionHandoffFlowRunner { - private readonly active = new Set>() - - constructor( - private readonly input: { - deps: StructuredAgentSessionHandoffDeps - operationGuard: StructuredAgentSessionHandoffOperationGuard - flowContext: () => StructuredAgentSessionHandoffFlowContext - fail: (params: AgentSessionHandoffRequest, error: unknown) => void - } - ) {} - - async drain(): Promise { - await Promise.allSettled(this.active) - } - - track(task: Promise): void { - this.active.add(task) - // Settle-only bookkeeping. `.finally` forwards a rejection onto a promise nobody awaits, so a - // failure notification that threw escaped as an unhandled rejection even though `drain` — the - // one consumer — settles the flow through `allSettled`. - const forget = (): void => void this.active.delete(task) - void task.then(forget, forget) - } - - begin(input: { - callerKey: string - params: AgentSessionHandoffRequest - turnId: string | null - fingerprint: string - tuiAlreadyExited?: boolean - }): void { - const { callerKey, params, turnId, fingerprint, tuiAlreadyExited = false } = input - const sessionId = params.envelope.sessionId - const journalSequence = this.input.deps.session(sessionId).journal.cursor().sequence - this.input.operationGuard.start(sessionId, { - callerKey, - operationId: params.envelope.clientOperationId, - fingerprint - }) - const flow = this.run(params, turnId, tuiAlreadyExited, journalSequence) - .then(() => { - this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId) - return this.input.deps.store.recordOperationOutcome({ - callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'succeeded', sessionId } - }) - }) - .catch(async (error) => { - try { - await this.input.deps.store.recordOperationOutcome({ - callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'failed', code: 'agent_session_handoff_failed' } - }) - } catch { - // Best-effort: a store write failure must not suppress the client's failure - // notification or leak the flow as an unhandled rejection. - } - this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId) - this.input.fail(params, error) - }) - .finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId)) - this.track(flow) - } - - private run( - params: AgentSessionHandoffRequest, - turnId: string | null, - tuiAlreadyExited: boolean, - journalSequence: number - ): Promise { - const sessionId = params.envelope.sessionId - return this.input.deps.schedule(sessionId, async () => { - const context = this.input.flowContext() - assertScheduledStructuredHandoffIsAdmissible({ - record: context.requireRecord(sessionId), - journal: this.input.deps.session(sessionId).journal, - params, - turnId, - journalSequence, - tuiAlreadyExited, - tuiStatus: structuredTuiStatus(context.owner(sessionId), this.input.deps.transport) - }) - if (turnId && params.mode === 'stop-turn') { - const stopped = await stopStructuredNativeTurn(this.input.deps, sessionId, turnId) - if (!stopped) { - throw new Error('The current turn did not acknowledge cancellation.') - } - } - await (params.direction === 'to-tui' - ? handoffStructuredSessionToTui(context, params, params.action === 'retry') - : handoffStructuredSessionToNative( - context, - params, - params.action === 'retry', - tuiAlreadyExited - )) - }) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts deleted file mode 100644 index b9559868f4bb..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { - abandonStoredAgentSessionHandoffAttempt, - rollbackStoredAgentSessionHandoffPreparation, - reserveStoredAgentSessionHandoffOwner, - stopStoredAgentSessionOwnerForHandoff -} from '../../runtime/agent-session-handoff-record-transitions' -import { AgentSessionAcquisitionExitUnprovenError } from './structured-agent-session-adapter' -import { markStructuredHandoffManualRecovery } from './structured-agent-session-handoff-flow-context' -import type { - StructuredAgentSessionHandoffFlowContext, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' -import { - StructuredTuiCatchupStoppedError, - StructuredTuiLaunchCleanupError -} from './structured-agent-session-handoff-types' - -export async function handoffStructuredSessionToTui( - context: StructuredAgentSessionHandoffFlowContext, - params: AgentSessionHandoffRequest, - retry: boolean -): Promise { - const { deps } = context - const sessionId = params.envelope.sessionId - const operationId = params.envelope.clientOperationId - let record = context.requireRecord(sessionId) - if (retry && record.lease.handoffStage === 'old-owner-stopped') { - record = await recoverNativeAfterTuiFailure(context, sessionId, operationId) - } - if (record.lease.handoffStage === null) { - await context.enterPreparing(record, operationId, 'to-tui') - } else if ( - record.lease.handoffStage !== 'preparing' || - record.lease.handoffOperationId !== operationId - ) { - throw new Error('agent_session_operation_conflict') - } - record = context.requireRecord(sessionId) - // A kill is a request. Everything below advances the fence and hands the - // provider session to a TUI, so an unproven exit must stop here rather than - // create a second live writer on the same thread. - let nativeSuspend - try { - nativeSuspend = await deps.suspendNative(sessionId) - } catch (error) { - await rollbackPreparingNativeOwner(context, sessionId, operationId) - throw error - } - if (nativeSuspend.state === 'live') { - await rollbackPreparingNativeOwner(context, sessionId, operationId) - throw new Error('agent_session_owner_exit_unproven') - } - record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { - sessionId, - expectedFence: record.lease.runtimeFence, - operationId, - now: deps.now() - }) - deps.acknowledgeNativeRelease?.(sessionId) - context.publishStage(record, 'to-tui') - if (nativeSuspend.state === 'stopped-cleanup-failed') { - await markStructuredHandoffManualRecovery(context, sessionId, operationId) - throw nativeSuspend.error - } - const spawnToken = randomUUID() - record = await reserveStoredAgentSessionHandoffOwner(deps.store, { - sessionId, - expectedFence: record.lease.runtimeFence, - runtimeKind: 'tui', - spawnToken, - operationId, - claimKeyId: deps.claimKeyId, - now: deps.now() - }) - context.publishStage(record, 'to-tui') - let owner: StructuredTuiOwner | null = null - let processIdentityCommitted = false - try { - const prepared = await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) - prepared?.throwIfAborted() - owner = await deps.transport!.launchTui({ - record, - fence: record.lease.runtimeFence, - spawnToken, - onSpawned: async (spawnedOwner) => { - owner = spawnedOwner - await deps.store.commitProcessIdentity({ - sessionId, - fence: record.lease.runtimeFence, - process: spawnedOwner.process, - now: deps.now() - }) - processIdentityCommitted = true - } - }) - prepared?.throwIfAborted() - if (!processIdentityCommitted) { - await deps.store.commitProcessIdentity({ - sessionId, - fence: record.lease.runtimeFence, - process: owner.process, - now: deps.now() - }) - } - record = await deps.store.proveOwner({ - sessionId, - fence: record.lease.runtimeFence, - link: owner.link, - now: deps.now() - }) - } catch (error) { - deps.stopTuiHistoryCatchup?.(sessionId) - if (!owner && error instanceof StructuredTuiLaunchCleanupError) { - await markStructuredHandoffManualRecovery(context, sessionId, operationId) - throw error - } - if (owner) { - if (!deps.transport?.stopFailedTuiLaunch) { - context.retainOwner(sessionId, owner) - await markStructuredHandoffManualRecovery(context, sessionId, operationId) - throw error - } - try { - await deps.transport.stopFailedTuiLaunch(owner) - } catch (stopError) { - context.retainOwner(sessionId, owner) - await markStructuredHandoffManualRecovery(context, sessionId, operationId) - throw new AggregateError( - [error, stopError], - 'The failed terminal launch could not be proven stopped.' - ) - } - } - if (error instanceof StructuredTuiCatchupStoppedError && (owner || !processIdentityCommitted)) { - await abandonStoredAgentSessionHandoffAttempt(deps.store, { - sessionId, - expectedFence: record.lease.runtimeFence, - operationId, - recoverableRuntimeKind: 'native', - now: deps.now() - }) - throw error - } - await recoverNativeAfterTuiFailure(context, sessionId, operationId) - throw error - } - context.retainOwner(sessionId, owner) - await deps.activateTuiHistoryCatchup?.(sessionId) - context.setStatus(sessionId, { - owner: 'tui', - direction: null, - phase: 'idle', - stage: record.lease.handoffStage, - operationId: record.lease.handoffOperationId, - terminal: owner.terminal, - hostLabel: deps.transport?.hostLabel - }) -} - -async function rollbackPreparingNativeOwner( - context: StructuredAgentSessionHandoffFlowContext, - sessionId: string, - operationId: string -): Promise { - const { deps } = context - const current = context.requireRecord(sessionId) - if ( - current.lease.handoffStage === 'preparing' && - current.lease.handoffOperationId === operationId && - current.lease.claimStatus === 'live' - ) { - await rollbackStoredAgentSessionHandoffPreparation(deps.store, { - sessionId, - expectedFence: current.lease.runtimeFence, - operationId, - now: deps.now() - }) - } -} - -async function recoverNativeAfterTuiFailure( - context: StructuredAgentSessionHandoffFlowContext, - sessionId: string, - operationId: string -) { - const { deps } = context - let record = context.requireRecord(sessionId) - if (record.lease.handoffStage === 'new-owner-proving') { - record = await abandonStoredAgentSessionHandoffAttempt(deps.store, { - sessionId, - expectedFence: record.lease.runtimeFence, - operationId, - recoverableRuntimeKind: 'native', - now: deps.now() - }) - } - const spawnToken = randomUUID() - record = await reserveStoredAgentSessionHandoffOwner(deps.store, { - sessionId, - expectedFence: record.lease.runtimeFence, - runtimeKind: 'native', - spawnToken, - operationId, - claimKeyId: deps.claimKeyId, - now: deps.now() - }) - try { - return await deps.acquireNative({ - sessionId, - fence: record.lease.runtimeFence, - spawnToken - }) - } catch (error) { - if (error instanceof AgentSessionAcquisitionExitUnprovenError) { - await markStructuredHandoffManualRecovery(context, sessionId, operationId) - throw error - } - const current = context.requireRecord(sessionId) - if (current.lease.handoffStage === 'new-owner-proving') { - await abandonStoredAgentSessionHandoffAttempt(deps.store, { - sessionId, - expectedFence: current.lease.runtimeFence, - operationId, - recoverableRuntimeKind: 'native', - now: deps.now() - }) - } - throw error - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts deleted file mode 100644 index 7c77806ad916..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session-record.test-fixture' -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffStatus -} from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { - queuedStructuredHandoffCanBegin, - StructuredAgentSessionHandoffQueue -} from './structured-agent-session-handoff-queue' -import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' -import { assertScheduledStructuredHandoffIsAdmissible } from './structured-agent-session-handoff-revalidation' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha-1' -const OPERATION_A = `${NOW}-00000000000000000000000000000001` -const OPERATION_B = `${NOW}-00000000000000000000000000000002` - -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - root = null - } -}) - -async function createGuard() { - root = await mkdtemp(join(tmpdir(), 'orca-handoff-operation-guard-')) - const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) - return { guard: new StructuredAgentSessionHandoffOperationGuard(store), store } -} - -function status(phase: 'switching' | 'queued' | 'idle'): AgentSessionHandoffStatus { - return { - owner: phase === 'idle' ? 'native' : 'none', - direction: phase === 'idle' ? null : 'to-tui', - phase, - stage: phase === 'switching' ? 'preparing' : null, - operationId: phase === 'idle' ? null : OPERATION_A - } -} - -describe('structured handoff operation ownership', () => { - it('reserves one winner across concurrent admissions', async () => { - const { guard } = await createGuard() - const check = (operationId: string) => - guard.check({ - callerKey: operationId, - sessionId: SESSION, - operationId, - fingerprint: operationId, - action: 'start', - now: NOW - }) - - const decisions = await Promise.all([check(OPERATION_A), check(OPERATION_B)]) - - expect(decisions.map(({ decision }) => decision).sort()).toEqual(['new', 'refused']) - }) - - it.each(['switching', 'queued'] as const)( - 'durably refuses a distinct operation while the %s operation owns the session', - async (phase) => { - const { guard } = await createGuard() - guard.start(SESSION, { - callerKey: 'client-a', - operationId: OPERATION_A, - fingerprint: 'fingerprint-a' - }) - - expect( - await guard.check({ - callerKey: 'client-b', - sessionId: SESSION, - operationId: OPERATION_B, - fingerprint: 'fingerprint-b', - action: 'start', - status: status(phase), - now: NOW - }) - ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) - - guard.finish(SESSION, OPERATION_A) - expect( - await guard.check({ - callerKey: 'client-b', - sessionId: SESSION, - operationId: OPERATION_B, - fingerprint: 'fingerprint-b', - action: 'start', - status: status('idle'), - now: NOW - }) - ).toMatchObject({ - decision: 'replay', - outcome: { status: 'failed', code: 'agent_session_operation_conflict' } - }) - } - ) - - it('admits only cancellation beside a queued operation', async () => { - const { guard } = await createGuard() - guard.start(SESSION, { - callerKey: 'client-a', - operationId: OPERATION_A, - fingerprint: 'fingerprint-a' - }) - - await expect( - guard.check({ - callerKey: 'client-b', - sessionId: SESSION, - operationId: OPERATION_B, - fingerprint: 'fingerprint-b', - action: 'cancel-queued', - status: status('queued'), - now: NOW - }) - ).resolves.toEqual({ decision: 'new' }) - }) -}) - -describe('queued handoff fence revalidation', () => { - const params: AgentSessionHandoffRequest = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION_A, - expectedRuntimeFence: 7, - payloadFingerprint: 'fingerprint' - }, - direction: 'to-tui', - mode: 'after-turn', - action: 'start' - } - const queued = status('queued') - - it('accepts the same live owner and fence', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', ownerProcess: null }) - ) - expect(queuedStructuredHandoffCanBegin(record, queued, params)).toBe(true) - }) - - it.each([ - agentSessionLeaseFixture({ runtimeKind: 'native', runtimeFence: 8, ownerProcess: null }), - agentSessionLeaseFixture({ runtimeKind: 'tui' }), - agentSessionLeaseFixture({ - runtimeKind: 'native', - ownerProcess: null, - handoffStage: 'preparing' - }) - ])('refuses a changed durable owner or fence', (lease) => { - expect(queuedStructuredHandoffCanBegin(agentSessionRecordFixture(lease), queued, params)).toBe( - false - ) - }) - - it('cannot cancel after the idle waiter claims the queued operation', async () => { - const queue = new StructuredAgentSessionHandoffQueue() - const ready = vi.fn() - queue.enqueue(SESSION, () => true, ready) - await vi.waitFor(() => expect(ready).toHaveBeenCalledOnce()) - expect(queue.cancel(SESSION)).toBe(false) - }) -}) - -describe('scheduled handoff revalidation', () => { - it('refuses a native turn accepted ahead of the scheduled handoff', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-handoff-revalidation-')) - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: SESSION, leafUuid: null } - }, - journalDir: join(root, 'journal') - }) - const journalSequence = journal.cursor().sequence - await journal.appendItem( - { provider: 'orca', clientMessageId: 'turn-running' }, - { kind: 'status', text: 'running', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 7 } - ) - const params: AgentSessionHandoffRequest = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION_A, - expectedRuntimeFence: 7, - payloadFingerprint: 'fingerprint' - }, - direction: 'to-tui', - mode: 'now', - action: 'start' - } - - expect(() => - assertScheduledStructuredHandoffIsAdmissible({ - record: agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', ownerProcess: null }) - ), - journal, - params, - turnId: null, - journalSequence, - tuiAlreadyExited: false, - tuiStatus: 'busy' - }) - ).toThrow('session changed') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts deleted file mode 100644 index da8d2eaad3d2..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import type { - AgentSessionOperationOutcome, - AgentSessionOperationRefusalCode -} from '../../../shared/agent-session-operation-ledger' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' - -type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string } - -export type HandoffOperationDecision = - | { decision: 'new' } - | { decision: 'replay'; outcome: AgentSessionOperationOutcome } - | { decision: 'retry' } - | { decision: 'refused'; code: AgentSessionOperationRefusalCode } - -export class StructuredAgentSessionHandoffOperationGuard { - private readonly activeBySession = new Map() - - constructor(private readonly store: AgentSessionRecordStore) {} - - async check(input: { - callerKey: string - sessionId: string - operationId: string - fingerprint: string - action: 'start' | 'cancel-queued' | 'retry' | 'recover' - status?: AgentSessionHandoffStatus - now: number - }): Promise { - const ledger = await this.store.admitOperation({ - callerKey: input.callerKey, - operationId: input.operationId, - fingerprint: input.fingerprint, - now: input.now - }) - if (ledger.decision === 'refused') { - return { decision: 'refused', code: ledger.code } - } - const active = this.activeBySession.get(input.sessionId) - const queuedCancellation = - input.action === 'cancel-queued' && - input.status?.phase === 'queued' && - input.status.operationId === active?.operationId - const activeConflict = Boolean( - active && - ((active.operationId === input.operationId && - (active.fingerprint !== input.fingerprint || active.callerKey !== input.callerKey)) || - (active.operationId !== input.operationId && !queuedCancellation)) - ) - const queuedConflict = Boolean( - !active && - input.status?.phase === 'queued' && - input.status.operationId !== input.operationId && - input.action !== 'cancel-queued' - ) - if (activeConflict || queuedConflict) { - if (ledger.decision === 'admit') { - await this.store.recordOperationOutcome({ - callerKey: input.callerKey, - operationId: input.operationId, - outcome: { status: 'failed', code: 'agent_session_operation_conflict' } - }) - } - return { decision: 'refused', code: 'agent_session_operation_conflict' } - } - if (ledger.decision === 'admit') { - this.reserve(input) - return { decision: 'new' } - } - if (input.action === 'retry' && ledger.row.outcome.status === 'failed') { - await this.store.recordOperationOutcome({ - callerKey: input.callerKey, - operationId: input.operationId, - outcome: { status: 'pending' } - }) - this.reserve(input) - return { decision: 'retry' } - } - if ( - ledger.row.outcome.status === 'pending' && - !active && - input.status?.operationId !== input.operationId - ) { - this.reserve(input) - return { decision: 'new' } - } - return { decision: 'replay', outcome: ledger.row.outcome } - } - - start(sessionId: string, operation: ActiveOperation): void { - this.activeBySession.set(sessionId, operation) - } - - private reserve(input: { - action: 'start' | 'cancel-queued' | 'retry' | 'recover' - callerKey: string - sessionId: string - operationId: string - fingerprint: string - }): void { - if (input.action !== 'cancel-queued') { - this.start(input.sessionId, input) - } - } - - finish(sessionId: string, operationId: string): void { - if (this.activeBySession.get(sessionId)?.operationId === operationId) { - this.activeBySession.delete(sessionId) - } - } - - async settle( - sessionId: string, - operationId: string, - outcome: AgentSessionOperationOutcome - ): Promise { - const active = this.activeBySession.get(sessionId) - await this.store.recordOperationOutcome({ - ...(active?.operationId === operationId ? { callerKey: active.callerKey } : {}), - operationId, - outcome - }) - this.finish(sessionId, operationId) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts deleted file mode 100644 index 420129792c96..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionHandoffDirection, - AgentSessionHandoffRequest, - AgentSessionMutationEnvelope -} from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -const CALLER = { callerKey: 'client-1' } -const DEFAULT_MODEL = 'gpt-default' -const PICKED_MODEL = 'gpt-picked' -const PICKED_EFFORT = 'medium' -const PICKED_FAST_MODE = true - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let activeModel: string -let activeEffort: string | null -let activeFastMode: boolean | null -let transcriptPath: string -let optionFailure: Error | null -const dispatchedModels: string[] = [] -const launchedOptions: (Readonly> | undefined)[] = [] -const closedTuiOwners: StructuredTuiOwner[] = [] - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function handoff(direction: AgentSessionHandoffDirection): AgentSessionHandoffRequest { - const fields = { direction, mode: 'now' as const, action: 'start' as const } - return { envelope: envelope('agentSession.requestHandoff', fields), ...fields } -} - -function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { - return { - terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, - process: { - hostId: 'local', - pid: 5200, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `tui-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - }, - transcriptPath - } -} - -function handoffTransport(): StructuredAgentSessionHandoffTransport { - return { - hostLabel: 'Test host', - launchTui: async ({ record, fence, spawnToken }) => { - launchedOptions.push(record.options) - return tuiOwner(fence, spawnToken) - }, - reproveTuiOwner: async ({ owner }) => owner, - recoverTuiOwner: async (record) => - tuiOwner( - record.lease.runtimeFence, - record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' - ), - stopRecoveredOwner: async () => undefined, - closeTuiOwner: async (owner) => { - closedTuiOwners.push(owner) - return { transcriptPath: owner.transcriptPath } - }, - waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle' - } -} - -function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options }) => { - activeModel = options?.model ?? DEFAULT_MODEL - activeEffort = options?.effort ?? null - activeFastMode = options?.fastMode === undefined ? null : options.fastMode === 'true' - return { - process: { - hostId: 'local', - pid: 4200 + acquire.mock.calls.length, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `native-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - return { - acquire, - dispatch: vi.fn(async () => { - dispatchedModels.push(activeModel) - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } - }), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async ({ key, value }) => { - if (optionFailure) { - const error = optionFailure - optionFailure = null - throw error - } - if (key === 'model') { - activeModel = value - } else if (key === 'effort') { - activeEffort = value - } else if (key === 'fastMode') { - activeFastMode = value === 'true' - } - return { - model: activeModel, - ...(activeEffort ? { effort: activeEffort } : {}), - ...(activeFastMode !== null ? { fastMode: String(activeFastMode) } : {}) - } - }), - readOptions: vi.fn(async () => ({ - current: { - model: activeModel, - ...(activeEffort ? { effort: activeEffort } : {}), - ...(activeFastMode !== null ? { fastMode: activeFastMode } : {}) - }, - models: [] - })), - closeSession: vi.fn(async () => { - activeModel = DEFAULT_MODEL - return true - }) - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-')) - resetHostTestOperationIds() - activeModel = DEFAULT_MODEL - activeEffort = null - activeFastMode = null - optionFailure = null - dispatchedModels.length = 0 - launchedOptions.length = 0 - closedTuiOwners.length = 0 - const accountHome = join(root, 'codex-home') - const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12') - transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`) - await mkdir(sessionsDir, { recursive: true }) - await writeFile( - transcriptPath, - `${JSON.stringify({ - type: 'session_meta', - timestamp: '2026-08-12T10:00:00.000Z', - payload: { id: THREAD, session_id: THREAD } - })}\n`, - 'utf8' - ) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - host = new StructuredAgentSessionHost({ - store, - adapter: adapter(), - journalRoot: root, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-native', - handoffTransport: handoffTransport(), - now: () => NOW - }) - const attached = await host.attach( - CALLER, - hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) - ) - expect(attached).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('structured session handoff options', () => { - it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { - optionFailure = new AgentSessionOptionRejectedError('model list unavailable') - const fields = { key: 'model', value: PICKED_MODEL } - const rejected = { - envelope: envelope('agentSession.setOption', fields), - ...fields - } - - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } - }) - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) - }) - - it('keeps a picked model through a native to TUI to native round trip', async () => { - const optionFields = { key: 'model', value: PICKED_MODEL } - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', optionFields), - ...optionFields - }) - ).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) - - const effortFields = { key: 'effort', value: PICKED_EFFORT } - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', effortFields), - ...effortFields - }) - ).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ - model: PICKED_MODEL, - effort: PICKED_EFFORT - }) - - const fastModeFields = { key: 'fastMode', value: String(PICKED_FAST_MODE) } - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fastModeFields), - ...fastModeFields - }) - ).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ - model: PICKED_MODEL, - effort: PICKED_EFFORT, - fastMode: 'true' - }) - - expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true }) - // Real-timer poll: the suite's default 1000ms budget is tight under a loaded CI shard. - await vi.waitFor( - async () => expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }), - { timeout: 5000 } - ) - expect(await host.requestHandoff(CALLER, handoff('to-native'))).toMatchObject({ ok: true }) - await vi.waitFor( - async () => expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }), - { timeout: 5000 } - ) - - expect(launchedOptions).toEqual([ - { model: PICKED_MODEL, effort: PICKED_EFFORT, fastMode: 'true' } - ]) - expect(closedTuiOwners).toHaveLength(1) - expect(acquire.mock.calls[1]?.[0].options).toEqual({ - model: PICKED_MODEL, - effort: PICKED_EFFORT, - fastMode: 'true' - }) - expect(store.getRecord(SESSION)?.options).toEqual({ - model: PICKED_MODEL, - effort: PICKED_EFFORT, - fastMode: 'true' - }) - const body = hostTestMessage('use the selected model') - expect( - await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - ).toMatchObject({ ok: true }) - expect(dispatchedModels).toEqual([PICKED_MODEL]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts deleted file mode 100644 index 567b4b7c256b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' - -export async function readNativeHandoffSessionOptions(input: { - adapter: Pick - sessionId: string - fence: number - priorOptions?: Readonly> -}): Promise> | undefined> { - const { adapter, sessionId, fence, priorOptions } = input - const reported = await adapter.readOptions?.({ - sessionId, - fence - }) - if (!reported) { - return undefined - } - const { model: _model, effort: _effort, fastMode: _fastMode, ...restored } = priorOptions ?? {} - const fastMode = - reported.current.fastMode === undefined - ? undefined - : encodeStructuredAgentSessionOptionValue('fastMode', reported.current.fastMode) - return { - ...restored, - model: reported.current.model, - ...(reported.current.effort ? { effort: reported.current.effort } : {}), - ...(fastMode !== undefined && fastMode !== null ? { fastMode } : {}) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts deleted file mode 100644 index a947b5e8ecce..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session-record.test-fixture' -import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' -import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' - -const NOW = 1_800_000_000_000 - -describe('closeRetainedTuiOwner', () => { - it('does not latch unexpected-exit settlement after an intentional close', async () => { - let record = agentSessionRecordFixture(agentSessionLeaseFixture()) - const closeTuiOwner = vi.fn(async () => ({})) - const releaseOwner = vi.fn() - const owner = { - terminal: { handle: 'terminal-1', tabId: 'tab-1', paneKey: 'pane-1', ptyId: 'pty-1' }, - process: record.lease.ownerProcess!, - link: record.providerHandleChain[0]! - } - const deps = { - store: { - transitionHandoff: async ( - _sessionId: string, - transition: (current: typeof record) => typeof record - ) => { - record = transition(record) - return record - } - }, - transport: { closeTuiOwner }, - now: () => NOW - } as unknown as StructuredAgentSessionHandoffDeps - - await closeRetainedTuiOwner({ - sessionId: record.sessionId, - deps, - owner: () => owner, - requireRecord: () => record, - releaseOwner - }) - - expect(closeTuiOwner).toHaveBeenCalledWith(owner) - expect(releaseOwner).toHaveBeenCalledWith(record.sessionId) - expect(record.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { kind: 'exit-observed' } - }) - expect(record.lease.settlementRetryRequired).toBeUndefined() - expect(record.lease.settlementRetryId).toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts deleted file mode 100644 index 9eb394734eb6..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { evictAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' -import type { - StructuredAgentSessionHandoffDeps, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -export async function closeRetainedTuiOwner(input: { - sessionId: string - deps: StructuredAgentSessionHandoffDeps - owner: (sessionId: string) => StructuredTuiOwner | undefined - requireRecord: (sessionId: string) => { lease: { runtimeFence: number } } - releaseOwner: (sessionId: string) => void -}): Promise { - const owner = input.owner(input.sessionId) - if (!owner) { - return false - } - const close = input.deps.transport?.closeTuiOwner ?? input.deps.transport?.waitForTuiExit - if (!close) { - throw new Error('The owning agent terminal could not be stopped.') - } - await close(owner) - const record = input.requireRecord(input.sessionId) - await input.deps.store.transitionHandoff(input.sessionId, (current) => - evictAgentSessionOwner({ - record: current, - expectedFence: record.lease.runtimeFence, - probe: { outcome: 'exit-observed' }, - now: input.deps.now(), - journalSettlement: 'not-required' - }) - ) - input.deps.stopTuiHistoryCatchup?.(input.sessionId) - input.releaseOwner(input.sessionId) - return true -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts deleted file mode 100644 index f8d6db2bace2..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import type { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' -import type { - StructuredAgentSessionHandoffDeps, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -export function queueStructuredHandoffAfterTurn(input: { - callerKey: string - params: AgentSessionHandoffRequest - deps: StructuredAgentSessionHandoffDeps - queue: StructuredAgentSessionHandoffQueue - owner: (sessionId: string) => StructuredTuiOwner | undefined - setStatus: ( - sessionId: string, - status: Parameters[1] - ) => void - begin: (callerKey: string, params: AgentSessionHandoffRequest, tuiAlreadyExited?: boolean) => void -}): void { - const { callerKey, params, deps, queue, owner, setStatus, begin } = input - const sessionId = params.envelope.sessionId - let tuiReadiness: 'idle' | 'exited' | null = null - setStatus(sessionId, { - owner: params.direction === 'to-tui' ? 'native' : 'tui', - direction: params.direction, - phase: 'queued', - stage: null, - operationId: params.envelope.clientOperationId, - hostLabel: deps.transport?.hostLabel - }) - const tuiOwner = owner(sessionId) - queue.enqueue( - sessionId, - async (signal) => { - if (params.direction === 'to-tui') { - return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) - } - tuiReadiness = tuiOwner - ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) - : null - return tuiReadiness !== null - }, - () => begin(callerKey, { ...params, mode: 'now' }, tuiReadiness === 'exited') - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts deleted file mode 100644 index 9ea3d7ff08fc..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffStatus -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import type { - StructuredAgentSessionHandoffDeps, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -export class StructuredAgentSessionHandoffQueue { - private readonly controllers = new Map() - - cancel(sessionId: string): boolean { - const controller = this.controllers.get(sessionId) - controller?.abort() - this.controllers.delete(sessionId) - return controller !== undefined - } - - enqueue( - sessionId: string, - isIdle: (signal: AbortSignal) => boolean | Promise, - onReady: () => void - ): void { - this.cancel(sessionId) - const controller = new AbortController() - this.controllers.set(sessionId, controller) - void this.waitUntilIdle(sessionId, controller, isIdle).then((ready) => { - if (ready) { - onReady() - } - }) - } - - private async waitUntilIdle( - sessionId: string, - controller: AbortController, - isIdle: (signal: AbortSignal) => boolean | Promise - ): Promise { - while (this.controllers.get(sessionId) === controller && !controller.signal.aborted) { - try { - if (await isIdle(controller.signal)) { - this.controllers.delete(sessionId) - return true - } - } catch { - if (controller.signal.aborted) { - return false - } - } - await new Promise((resolve) => setTimeout(resolve, 150)) - } - return false - } -} - -export function queuedStructuredHandoffCanBegin( - record: AgentSessionRecord, - status: AgentSessionHandoffStatus, - params: AgentSessionHandoffRequest -): boolean { - const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' - return ( - record.sessionId === params.envelope.sessionId && - status.phase === 'queued' && - status.direction === params.direction && - status.operationId === params.envelope.clientOperationId && - record.lease.runtimeFence === params.envelope.expectedRuntimeFence && - record.lease.runtimeKind === expectedOwner && - record.lease.claimStatus === 'live' && - record.lease.handoffStage === null && - !record.lease.unreconciled - ) -} - -export function enqueueStructuredHandoffAfterTurn(input: { - deps: StructuredAgentSessionHandoffDeps - queue: StructuredAgentSessionHandoffQueue - params: AgentSessionHandoffRequest - tuiOwner: StructuredTuiOwner | undefined - status: () => AgentSessionHandoffStatus - requireRecord: () => AgentSessionRecord - setStatus: (status: AgentSessionHandoffStatus) => void - begin: (params: AgentSessionHandoffRequest, tuiAlreadyExited: boolean) => void - refuse: (record: AgentSessionRecord) => void -}): void { - const { deps, params, queue, tuiOwner } = input - const sessionId = params.envelope.sessionId - let tuiReadiness: 'idle' | 'exited' | null = null - let observedTuiQueue = false - input.setStatus({ - owner: params.direction === 'to-tui' ? 'native' : 'tui', - direction: params.direction, - phase: 'queued', - stage: null, - operationId: params.envelope.clientOperationId, - hostLabel: deps.transport?.hostLabel - }) - queue.enqueue( - sessionId, - async (signal) => { - if (params.direction === 'to-tui') { - return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) - } - if (!observedTuiQueue) { - observedTuiQueue = true - return false - } - tuiReadiness = tuiOwner - ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) - : null - if (tuiReadiness === 'exited') { - return true - } - if (!activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items)) { - tuiReadiness = 'idle' - return true - } - return false - }, - () => { - const record = input.requireRecord() - const status = input.status() - if (!queuedStructuredHandoffCanBegin(record, status, params)) { - input.refuse(record) - return - } - input.begin(params, tuiReadiness === 'exited') - } - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts deleted file mode 100644 index 0aab4f0335b8..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffStatus -} from '../../../shared/agent-session-wire' -import { - beginStructuredManualRecovery, - structuredManualRecoveryIsAdmissible -} from './structured-agent-session-manual-recovery' -import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' -import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' - -export async function requestStructuredManualRecovery(input: { - deps: StructuredAgentSessionHandoffDeps - operationGuard: StructuredAgentSessionHandoffOperationGuard - callerKey: string - params: AgentSessionHandoffRequest - fingerprint: string - record: AgentSessionRecord - status: AgentSessionHandoffStatus - requireRecord: (sessionId: string) => AgentSessionRecord - restore: (sessionId: string) => Promise - setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void -}): Promise { - if (!structuredManualRecoveryIsAdmissible(input.record, input.status)) { - return false - } - beginStructuredManualRecovery(input) - return true -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts deleted file mode 100644 index 3bf0bc08e8c1..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' -import { - abandonStoredAgentSessionHandoffAttempt, - stopStoredAgentSessionOwnerForHandoff, - stopStoredRecoveringTuiOwnerForHandoff -} from '../../runtime/agent-session-handoff-record-transitions' -import type { - StructuredAgentSessionHandoffDeps, - StructuredAgentSessionHandoffFlowContext, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -export type StructuredAgentSessionRestartAccess = { - deps: StructuredAgentSessionHandoffDeps - requireRecord: (sessionId: string) => AgentSessionRecord - flowContext: () => StructuredAgentSessionHandoffFlowContext - retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void - setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void -} - -type ContinueHandoff = ( - input: StructuredAgentSessionRestartAccess, - record: AgentSessionRecord -) => Promise - -export async function recoverUnavailableTuiAsNative( - input: StructuredAgentSessionRestartAccess, - record: AgentSessionRecord, - continueHandoff: ContinueHandoff -): Promise { - await input.deps.transport!.stopRecoveredOwner(record) - if (record.lease.handoffStage === 'preparing') { - const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { - sessionId: record.sessionId, - expectedFence: record.lease.runtimeFence, - operationId: record.lease.handoffOperationId!, - now: input.deps.now() - }) - await continueHandoff(input, stopped) - return - } - if (record.lease.handoffStage === 'new-owner-proving') { - const abandoned = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { - sessionId: record.sessionId, - expectedFence: record.lease.runtimeFence, - operationId: record.lease.handoffOperationId!, - recoverableRuntimeKind: 'native', - now: input.deps.now() - }) - await continueHandoff(input, abandoned) - return - } - const operationId = createStructuredAgentSessionOperationId(randomUUID, input.deps.now()) - const stopped = await stopStoredRecoveringTuiOwnerForHandoff(input.deps.store, { - sessionId: record.sessionId, - expectedFence: record.lease.runtimeFence, - operationId, - now: input.deps.now() - }) - await continueHandoff(input, stopped) -} - -export async function recoverTuiOwnerOrContinue( - input: StructuredAgentSessionRestartAccess, - record: AgentSessionRecord, - continueHandoff: ContinueHandoff -): Promise { - try { - const owner = await input.deps.transport!.recoverTuiOwner(record) - const reproved = await input.deps.transport!.reproveTuiOwner({ record, owner }) - await persistReprovedTuiOwner(input, record.sessionId, reproved) - return reproved - } catch (error) { - const ownerState = await input.deps.transport!.probeRecoveredOwner?.(record) - if (ownerState !== 'dead') { - throw error - } - await recoverUnavailableTuiAsNative(input, record, continueHandoff) - return null - } -} - -export async function persistReprovedTuiOwner( - input: StructuredAgentSessionRestartAccess, - sessionId: string, - owner: StructuredTuiOwner -): Promise { - if (owner.link.origin === 'resumed') { - await input.deps.persistTuiProviderHandle?.({ - sessionId, - link: owner.link, - now: input.deps.now() - }) - } -} - -export async function startRecoveredTuiCatchup( - input: StructuredAgentSessionRestartAccess, - record: AgentSessionRecord -): Promise { - const prepared = await input.deps.recoverTuiHistoryCatchup?.( - record.sessionId, - record.lease.runtimeFence - ) - prepared?.throwIfAborted() - await input.deps.activateTuiHistoryCatchup?.(record.sessionId) - prepared?.throwIfAborted() -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts deleted file mode 100644 index a6ad92d91eec..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { - abandonStoredAgentSessionHandoffAttempt, - setStoredAgentSessionHandoffStage, - stopStoredAgentSessionOwnerForHandoff -} from '../../runtime/agent-session-handoff-record-transitions' -import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' -import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' -import { - idleStructuredHandoffStatus, - structuredTuiRecoveryProofIsAdmissible -} from './structured-agent-session-handoff-status' -import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' -import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' -import { - persistReprovedTuiOwner, - recoverTuiOwnerOrContinue, - recoverUnavailableTuiAsNative, - startRecoveredTuiCatchup, - type StructuredAgentSessionRestartAccess -} from './structured-agent-session-handoff-restart-tui' - -type RestartAccess = StructuredAgentSessionRestartAccess - -export async function restoreStructuredAgentSessionHandoff( - input: RestartAccess, - sessionId: string -): Promise { - const initial = input.requireRecord(sessionId) - const operationId = initial.lease.handoffOperationId - const initialStage = initial.lease.handoffStage - if ( - (initialStage === 'recovering' || initialStage === 'manual-recovery') && - !canRestoreLiveTuiOwner(initial) - ) { - if (operationId) { - await input.deps.store.recordOperationOutcome({ - operationId, - outcome: { status: 'failed', code: 'agent_session_ownership_unknown' } - }) - } - input.setStatus(sessionId, idleStructuredHandoffStatus(initial)) - return - } - let lastError: unknown - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await restoreOnce(input, input.requireRecord(sessionId)) - const settled = input.requireRecord(sessionId) - if (settled.lease.handoffStage !== null || settled.lease.handoffOperationId !== null) { - throw new Error('Restart handoff reconciliation did not settle the transfer.') - } - if (operationId) { - await input.deps.store.recordOperationOutcome({ - operationId, - outcome: { status: 'succeeded', sessionId } - }) - } - return - } catch (error) { - if (error instanceof StructuredTuiCatchupStoppedError) { - if (operationId) { - await input.deps.store.recordOperationOutcome({ - operationId, - outcome: { status: 'failed', code: 'agent_session_handoff_failed' } - }) - } - throw error - } - lastError = error - if (attempt < 2) { - await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) - } - } - } - const current = input.requireRecord(sessionId) - const failed = await setStoredAgentSessionHandoffStage(input.deps.store, { - sessionId, - fence: current.lease.runtimeFence, - stage: 'manual-recovery', - // Keep a live TUI retryable after restart; other records retain the failed operation. - handoffOperationId: - current.lease.runtimeKind === 'tui' && current.lease.claimStatus === 'live' - ? null - : operationId, - now: input.deps.now() - }) - const status = idleStructuredHandoffStatus(failed) - if (operationId) { - await input.deps.store.recordOperationOutcome({ - operationId, - outcome: { status: 'failed', code: 'agent_session_handoff_failed' } - }) - } - input.setStatus(sessionId, { - ...status, - ...(status.error - ? { - error: { - ...status.error, - details: lastError instanceof Error ? lastError.message : String(lastError) - } - } - : {}) - }) -} - -async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise { - if ( - record.lease.handoffStage === null && - record.lease.claimStatus === 'released' && - record.lease.ownerProcess === null - ) { - // Reconcile already proved the owner gone: no transfer is in flight and there is no process to - // recover, whatever kind the last owner was. Falling through would latch manual recovery on a - // host with no TUI transport — a state a user then has to clear by hand, for nothing. (Startup - // used to reach this path only for sessions it had not eagerly resumed, which is why removing - // that resume is what made it visible.) - return - } - if (canRestoreLiveTuiOwner(record)) { - await restoreRecoverableLiveTui(input, record) - return - } - if (!input.deps.transport) { - if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') { - throw new Error('Agent TUI handoff recovery is unavailable on this host.') - } - return - } - if (record.lease.handoffStage === null && record.lease.runtimeKind === 'tui') { - await restoreLiveTui(input, record) - return - } - if (!record.lease.handoffOperationId) { - return - } - if (record.lease.handoffStage === 'preparing') { - await restorePreparing(input, record) - return - } - if (record.lease.handoffStage === 'new-owner-proving') { - await restoreProving(input, record) - return - } - if (record.lease.handoffStage === 'old-owner-stopped') { - await continueHandoff(input, record) - } -} - -export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean { - return structuredTuiRecoveryProofIsAdmissible(record) -} - -async function restoreRecoverableLiveTui( - input: RestartAccess, - record: AgentSessionRecord -): Promise { - if (!input.deps.transport) { - throw new Error('Agent TUI handoff recovery is unavailable on this host.') - } - let owner: StructuredTuiOwner - try { - const recovered = await input.deps.transport.recoverTuiOwner(record) - owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered }) - await persistReprovedTuiOwner(input, record.sessionId, owner) - } catch (error) { - const ownerState = await input.deps.transport.probeRecoveredOwner?.(record) - if (ownerState !== 'dead') { - throw error - } - await recoverUnavailableTuiAsNative(input, record, continueHandoff) - return - } - let settled = input.deps.store.getRecord(record.sessionId) ?? record - if (settled.lease.claimStatus === 'reserved') { - settled = await setStoredAgentSessionHandoffStage(input.deps.store, { - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - stage: 'new-owner-proving', - handoffOperationId: null, - now: input.deps.now() - }) - settled = await input.deps.store.proveOwner({ - sessionId: settled.sessionId, - fence: settled.lease.runtimeFence, - link: owner.link, - now: input.deps.now() - }) - } else { - settled = await setStoredAgentSessionHandoffStage(input.deps.store, { - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - stage: null, - handoffOperationId: null, - now: input.deps.now() - }) - } - input.retainOwner(record.sessionId, owner) - await startRecoveredTuiCatchup(input, record) - input.setStatus(record.sessionId, { - owner: 'tui', - direction: null, - phase: 'idle', - stage: null, - operationId: null, - terminal: owner.terminal, - hostLabel: input.deps.transport.hostLabel - }) -} - -async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise { - const owner = await input.deps.transport!.recoverTuiOwner(record) - await persistReprovedTuiOwner(input, record.sessionId, owner) - input.retainOwner(record.sessionId, owner) - await startRecoveredTuiCatchup(input, record) - input.setStatus(record.sessionId, { - owner: 'tui', - direction: null, - phase: 'idle', - stage: null, - operationId: null, - terminal: owner.terminal, - hostLabel: input.deps.transport?.hostLabel - }) -} - -async function restorePreparing(input: RestartAccess, record: AgentSessionRecord): Promise { - if (record.lease.runtimeKind === 'tui') { - const owner = await recoverTuiOwnerOrContinue(input, record, continueHandoff) - if (!owner) { - return - } - const settled = await setStoredAgentSessionHandoffStage(input.deps.store, { - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - stage: null, - handoffOperationId: null, - now: input.deps.now() - }) - await restoreLiveTui(input, settled) - return - } - await input.deps.transport!.stopRecoveredOwner(record) - const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { - sessionId: record.sessionId, - expectedFence: record.lease.runtimeFence, - operationId: record.lease.handoffOperationId!, - now: input.deps.now() - }) - await continueHandoff(input, stopped) -} - -async function restoreProving(input: RestartAccess, record: AgentSessionRecord): Promise { - const operationId = record.lease.handoffOperationId! - if (record.lease.runtimeKind === 'tui') { - const reproved = await recoverTuiOwnerOrContinue(input, record, continueHandoff) - if (!reproved) { - return - } - await input.deps.store.proveOwner({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - link: reproved.link, - now: input.deps.now() - }) - input.retainOwner(record.sessionId, reproved) - await startRecoveredTuiCatchup(input, record) - input.setStatus(record.sessionId, { - owner: 'tui', - direction: null, - phase: 'idle', - stage: null, - operationId: null, - terminal: reproved.terminal, - hostLabel: input.deps.transport?.hostLabel - }) - return - } - await input.deps.transport!.stopRecoveredOwner(record) - const stopped = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { - sessionId: record.sessionId, - expectedFence: record.lease.runtimeFence, - operationId, - recoverableRuntimeKind: 'tui', - now: input.deps.now() - }) - await continueHandoff(input, stopped) -} - -async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { - const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native' - const operationId = record.lease.handoffOperationId! - const params: AgentSessionHandoffRequest = { - envelope: { - sessionId: record.sessionId, - clientOperationId: operationId, - expectedRuntimeFence: record.lease.runtimeFence, - payloadFingerprint: 'restart-reconciliation' - }, - direction, - mode: 'now', - action: 'retry' - } - await (direction === 'to-tui' - ? handoffStructuredSessionToTui(input.flowContext(), params, true) - : handoffStructuredSessionToNative(input.flowContext(), params, true)) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts deleted file mode 100644 index fe480d6359cc..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - AgentSessionHandoffResult, - AgentSessionMutationResult, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' - -export function structuredHandoffRefusal( - code: AgentSessionWireRefusal['code'], - message: string -): AgentSessionWireRefusal { - return { code, message } -} - -export function structuredHandoffSuccess( - deps: StructuredAgentSessionHandoffDeps, - sessionId: string, - replayed: boolean, - status: AgentSessionHandoffResult['status'] -): AgentSessionMutationResult { - const record = deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return { - ok: true, - replayed, - fence: record.lease.runtimeFence, - cursor: deps.session(sessionId).journal.cursor(), - value: { status } - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts deleted file mode 100644 index 66005959378b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { structuredHandoffRetryResumesStoppedOwner } from './structured-agent-session-handoff-admission' -import { structuredSessionHasPendingPrompt } from './structured-agent-session-handoff-status' - -export function assertScheduledStructuredHandoffIsAdmissible(input: { - record: AgentSessionRecord - journal: AgentSessionJournal - params: AgentSessionHandoffRequest - turnId: string | null - journalSequence: number - tuiAlreadyExited: boolean - tuiStatus: 'idle' | 'busy' -}): void { - const { params, record } = input - if (params.action === 'retry' && structuredHandoffRetryResumesStoppedOwner(record, params)) { - return - } - const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' - if ( - record.lease.runtimeFence !== params.envelope.expectedRuntimeFence || - record.lease.runtimeKind !== expectedOwner || - record.lease.claimStatus !== 'live' || - record.lease.handoffStage !== null || - record.lease.unreconciled - ) { - throw new Error('agent_session_checkpoint_stale') - } - if (structuredSessionHasPendingPrompt(input.journal)) { - throw new Error('Resolve the pending question or approval before switching.') - } - if (params.mode !== 'stop-turn' && input.journal.cursor().sequence !== input.journalSequence) { - throw new Error('The session changed before the handoff started.') - } - const activeTurn = activeStructuredAgentSessionTurnId(input.journal.snapshot().items) - if (params.direction === 'to-tui') { - const expectedTurn = params.mode === 'stop-turn' ? input.turnId : null - if (activeTurn !== expectedTurn) { - throw new Error('The native turn changed before the handoff started.') - } - return - } - if ( - !input.tuiAlreadyExited && - input.tuiStatus !== 'idle' && - (params.mode !== 'after-turn' || activeTurn !== null) - ) { - throw new Error('The agent terminal became busy before the handoff started.') - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts deleted file mode 100644 index 91c6163bd174..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' -import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' - -const OPERATION_ID = 'operation-1' -const SESSION_ID = 'session-1' - -vi.mock('../../runtime/agent-session-handoff-record-transitions', () => ({ - abandonStoredAgentSessionHandoffAttempt: vi.fn(async () => undefined), - reserveStoredAgentSessionHandoffOwner: vi.fn(async () => record()), - rollbackStoredAgentSessionHandoffPreparation: vi.fn(async () => undefined), - stopStoredAgentSessionOwnerForHandoff: vi.fn(async () => record()) -})) - -function record(): AgentSessionRecord { - return { - sessionId: SESSION_ID, - provider: 'claude', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - lease: { - runtimeFence: 3, - handoffStage: 'old-owner-stopped', - handoffOperationId: OPERATION_ID - } - } as unknown as AgentSessionRecord -} - -function contextWith( - revealNativeSession: () => Promise, - statuses: AgentSessionHandoffStatus[] -): StructuredAgentSessionHandoffFlowContext { - return { - deps: { - store: {} as never, - claimKeyId: 'key-1', - now: () => 1_800_000_000_000, - importTuiHistory: vi.fn(async () => undefined), - acquireNative: vi.fn(async () => record()), - transport: { revealNativeSession } - } as never, - owner: () => undefined, - retainOwner: vi.fn(), - releaseOwner: vi.fn(), - setStatus: (_sessionId, status) => statuses.push(status), - enterPreparing: vi.fn(async () => undefined), - publishStage: vi.fn(), - requireRecord: () => record() - } -} - -// Why this ordering matters: releaseOwner has already run by the time the reveal fires, -// so a reveal that rejects before the status flip leaves the session released but never -// marked native — a stuck chat with no owner on either side. -describe('handoffStructuredSessionToNative', () => { - it('marks the session native before revealing it', async () => { - const statuses: AgentSessionHandoffStatus[] = [] - const order: string[] = [] - const context = contextWith(async () => { - order.push('reveal') - }, statuses) - const setStatus = context.setStatus - context.setStatus = (sessionId, status) => { - order.push('status') - setStatus(sessionId, status) - } - - await handoffStructuredSessionToNative( - context, - { envelope: { sessionId: SESSION_ID, clientOperationId: OPERATION_ID } } as never, - true - ) - - expect(order).toEqual(['status', 'reveal']) - expect(statuses.at(-1)).toMatchObject({ owner: 'native', direction: null, phase: 'idle' }) - }) - - it('still leaves the session marked native when the reveal rejects', async () => { - const statuses: AgentSessionHandoffStatus[] = [] - const context = contextWith(async () => { - throw new Error('publish failed') - }, statuses) - - await expect( - handoffStructuredSessionToNative( - context, - { envelope: { sessionId: SESSION_ID, clientOperationId: OPERATION_ID } } as never, - true - ) - ).rejects.toThrow('publish failed') - - expect(statuses.at(-1)).toMatchObject({ owner: 'native' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts deleted file mode 100644 index 1dcd4904895a..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { - abandonStoredAgentSessionHandoffAttempt, - reserveStoredAgentSessionHandoffOwner, - rollbackStoredAgentSessionHandoffPreparation, - stopStoredAgentSessionOwnerForHandoff -} from '../../runtime/agent-session-handoff-record-transitions' -import { AgentSessionAcquisitionExitUnprovenError } from './structured-agent-session-adapter' -import { markStructuredHandoffManualRecovery } from './structured-agent-session-handoff-flow-context' -import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' - -export async function handoffStructuredSessionToNative( - context: StructuredAgentSessionHandoffFlowContext, - params: AgentSessionHandoffRequest, - retry: boolean, - tuiAlreadyExited = false -): Promise { - const { deps } = context - const sessionId = params.envelope.sessionId - const operationId = params.envelope.clientOperationId - let record = context.requireRecord(sessionId) - let owner = context.owner(sessionId) - let transcriptPath = owner?.transcriptPath - if (!retry || record.lease.handoffStage === 'preparing' || record.lease.handoffStage === null) { - if (record.lease.handoffStage === null) { - await context.enterPreparing(record, operationId, 'to-native') - } - record = context.requireRecord(sessionId) - try { - if (!owner) { - throw new Error('The owning agent terminal could not be identified.') - } - if (!tuiAlreadyExited) { - owner = await deps.transport!.reproveTuiOwner({ record, owner }) - context.retainOwner(sessionId, owner) - if (owner.link.origin === 'resumed') { - await deps.persistTuiProviderHandle?.({ sessionId, link: owner.link, now: deps.now() }) - } - } - transcriptPath = owner.transcriptPath ?? transcriptPath - if (owner.link.handle.provider === 'codex' && !transcriptPath) { - throw new Error( - 'The Codex terminal has not written a durable rollout yet. Send a prompt before switching to structured chat.' - ) - } - context.setStatus(sessionId, { - owner: 'tui', - direction: 'to-native', - phase: 'waiting-for-exit', - stage: 'preparing', - operationId, - terminal: owner.terminal, - hostLabel: deps.transport?.hostLabel - }) - const exited = deps.transport!.closeTuiOwner - ? await deps.transport!.closeTuiOwner(owner) - : await deps.transport!.waitForTuiExit(owner) - transcriptPath = exited.transcriptPath ?? owner.transcriptPath - } catch (error) { - const current = context.requireRecord(sessionId) - if ( - current.lease.handoffStage === 'preparing' && - current.lease.handoffOperationId === operationId && - current.lease.claimStatus === 'live' && - current.lease.ownerProcess - ) { - await rollbackStoredAgentSessionHandoffPreparation(deps.store, { - sessionId, - expectedFence: current.lease.runtimeFence, - operationId, - now: deps.now() - }) - } - throw error - } - record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { - sessionId, - expectedFence: record.lease.runtimeFence, - operationId, - now: deps.now() - }) - context.publishStage(record, 'to-native') - } else if ( - record.lease.handoffStage !== 'old-owner-stopped' || - record.lease.handoffOperationId !== operationId - ) { - throw new Error('agent_session_operation_conflict') - } - deps.stopTuiHistoryCatchup?.(sessionId) - if (owner?.historySource !== 'provider-resume') { - await deps.importTuiHistory({ - sessionId, - fence: record.lease.runtimeFence, - ...(transcriptPath ? { transcriptPath } : {}) - }) - } - if (record.lease.settlementRetryRequired) { - const settled = await deps.retryPendingSettlement(sessionId) - if (!settled) { - throw new Error('The provider-exit terminal journal settlement is still pending.') - } - record = context.requireRecord(sessionId) - } - const spawnToken = randomUUID() - record = await reserveStoredAgentSessionHandoffOwner(deps.store, { - sessionId, - expectedFence: record.lease.runtimeFence, - runtimeKind: 'native', - spawnToken, - operationId, - claimKeyId: deps.claimKeyId, - now: deps.now() - }) - context.publishStage(record, 'to-native') - try { - record = await deps.acquireNative({ - sessionId, - fence: record.lease.runtimeFence, - spawnToken - }) - } catch (error) { - if (error instanceof AgentSessionAcquisitionExitUnprovenError) { - await markStructuredHandoffManualRecovery(context, sessionId, operationId) - throw error - } - const current = context.requireRecord(sessionId) - if (current.lease.handoffStage === 'new-owner-proving') { - await abandonStoredAgentSessionHandoffAttempt(deps.store, { - sessionId, - expectedFence: current.lease.runtimeFence, - operationId, - recoverableRuntimeKind: 'tui', - now: deps.now() - }) - } - throw error - } - context.releaseOwner(sessionId) - // Why status lands before the reveal: the native owner is already proven here, and a - // reveal that rejects must not leave the session released but never marked native. - context.setStatus(sessionId, { - owner: 'native', - direction: null, - phase: 'idle', - stage: record.lease.handoffStage, - operationId: record.lease.handoffOperationId - }) - await deps.transport?.revealNativeSession?.({ - workspaceId: record.location.workspaceId, - sessionId, - agent: record.provider, - ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts deleted file mode 100644 index 2917fca2fe23..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' -import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' - -export class StructuredAgentSessionHandoffState { - private readonly statuses = new Map() - private readonly tuiOwners = new Map() - - constructor( - private readonly deps: { - requireRecord: (sessionId: string) => AgentSessionRecord - publish: (sessionId: string, status: AgentSessionHandoffStatus) => void - hostLabel?: string - } - ) {} - - status = (sessionId: string): AgentSessionHandoffStatus => { - const value = this.statuses.get(sessionId) - return value ?? idleStructuredHandoffStatus(this.deps.requireRecord(sessionId)) - } - - cachedStatus = (sessionId: string): AgentSessionHandoffStatus | undefined => - this.statuses.get(sessionId) - - owner = (sessionId: string): StructuredTuiOwner | undefined => this.tuiOwners.get(sessionId) - - retainOwner = (sessionId: string, owner: StructuredTuiOwner): void => { - this.tuiOwners.set(sessionId, owner) - } - - releaseOwner = (sessionId: string): void => void this.tuiOwners.delete(sessionId) - - setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => { - this.statuses.set(sessionId, status) - this.deps.publish(sessionId, status) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts deleted file mode 100644 index 7d519a558b59..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffStatus -} from '../../../shared/agent-session-wire' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -export function structuredTuiStatus( - owner: StructuredTuiOwner | undefined, - transport: StructuredAgentSessionHandoffTransport | undefined -): 'idle' | 'busy' { - return owner ? (transport?.tuiStatus(owner) ?? 'busy') : 'busy' -} - -export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus { - if ( - record.lease.handoffStage === 'old-owner-stopped' && - record.lease.claimStatus === 'released' && - record.lease.handoffOperationId - ) { - return persistedFailedStructuredHandoffStatus(record) - } - if (record.lease.handoffStage === 'manual-recovery') { - const canRetryProof = structuredTuiRecoveryProofIsAdmissible(record) - return { - owner: 'none', - direction: record.lease.runtimeKind === 'tui' ? 'to-tui' : 'to-native', - phase: 'failed', - stage: 'manual-recovery', - operationId: record.lease.handoffOperationId, - error: { - message: "Couldn't verify which runtime owns this session — manual recovery is required", - recoverableOwner: 'none', - ...(canRetryProof ? { canRetryProof: true } : {}) - } - } - } - if (record.lease.handoffStage) { - const direction = - record.lease.handoffStage === 'preparing' - ? record.lease.runtimeKind === 'native' - ? 'to-tui' - : 'to-native' - : record.lease.runtimeKind === 'tui' - ? 'to-tui' - : 'to-native' - return { - owner: - record.lease.claimStatus === 'live' && record.lease.ownerProcess - ? record.lease.runtimeKind - : 'none', - direction, - phase: 'switching', - stage: record.lease.handoffStage, - operationId: record.lease.handoffOperationId - } - } - return { - owner: record.lease.claimStatus === 'live' ? record.lease.runtimeKind : 'none', - direction: null, - phase: 'idle', - stage: record.lease.handoffStage, - operationId: record.lease.handoffOperationId - } -} - -function persistedFailedStructuredHandoffStatus( - record: AgentSessionRecord -): AgentSessionHandoffStatus { - const recoverableOwner = record.lease.runtimeKind - const direction = recoverableOwner === 'native' ? 'to-tui' : 'to-native' - return { - owner: recoverableOwner, - direction, - phase: 'failed', - stage: 'old-owner-stopped', - operationId: record.lease.handoffOperationId, - error: { - message: - direction === 'to-tui' - ? "Couldn't open the agent terminal — chat still owns this session" - : "Couldn't resume chat — the agent terminal still owns this session", - recoverableOwner - } - } -} - -export function structuredSessionHasPendingPrompt(journal: AgentSessionJournal): boolean { - return journal - .snapshot() - .items.some( - (item) => - (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' - ) -} - -export function switchingStructuredHandoffStatus( - record: AgentSessionRecord, - direction: 'to-tui' | 'to-native', - hostLabel?: string -): AgentSessionHandoffStatus { - return { - owner: record.lease.ownerProcess ? record.lease.runtimeKind : 'none', - direction, - phase: 'switching', - stage: record.lease.handoffStage, - operationId: record.lease.handoffOperationId, - ...(hostLabel ? { hostLabel } : {}) - } -} - -export function failedStructuredHandoffStatus( - record: AgentSessionRecord, - params: AgentSessionHandoffRequest, - error: unknown, - hostLabel?: string -): AgentSessionHandoffStatus { - const recoverableOwner = - record.lease.handoffStage === 'manual-recovery' - ? 'none' - : record.lease.handoffStage === 'old-owner-stopped' && record.lease.claimStatus === 'released' - ? record.lease.runtimeKind - : record.lease.ownerProcess - ? record.lease.runtimeKind - : params.direction === 'to-tui' && record.lease.runtimeKind === 'native' - ? 'native' - : 'none' - const canRetryProof = structuredTuiRecoveryProofIsAdmissible(record) - return { - owner: recoverableOwner, - direction: params.direction, - phase: 'failed', - stage: record.lease.handoffStage, - operationId: params.envelope.clientOperationId, - ...(hostLabel ? { hostLabel } : {}), - error: { - message: - recoverableOwner === 'none' - ? "Couldn't verify which runtime owns this session — manual recovery is required" - : params.direction === 'to-tui' - ? "Couldn't open the agent terminal — chat still owns this session" - : "Couldn't resume chat — the agent terminal still owns this session", - details: error instanceof Error ? error.message : String(error), - recoverableOwner, - ...(canRetryProof ? { canRetryProof: true } : {}) - } - } -} - -/** A latched TUI with a recorded process can be re-proved, regardless of which acquisition - * phase was interrupted. The operation ledger, not the lease, records the failed attempt. */ -export function structuredTuiRecoveryProofIsAdmissible(record: AgentSessionRecord): boolean { - return ( - (record.lease.handoffStage === 'recovering' || - record.lease.handoffStage === 'manual-recovery') && - record.lease.runtimeKind === 'tui' && - record.lease.ownerProcess !== null && - ((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) || - (record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null)) - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts deleted file mode 100644 index 81ef23aeb3b7..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -type TestCoordinatorInput = { - store: AgentSessionRecordStore - journal: AgentSessionJournal - sessionId: string - provider: 'claude' | 'codex' - claudeSessionId: string - codexThreadId: string - now: number - launchTui: StructuredAgentSessionHandoffTransport['launchTui'] - reproveTuiOwner: StructuredAgentSessionHandoffTransport['reproveTuiOwner'] - stopRecoveredOwner: StructuredAgentSessionHandoffTransport['stopRecoveredOwner'] - closeTuiOwner: NonNullable - waitForTuiExit: StructuredAgentSessionHandoffTransport['waitForTuiExit'] - waitForTuiIdleOrExit: StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit'] - stopFailedTuiLaunch: NonNullable - recoverTuiOwner: (record: AgentSessionRecord) => Promise - tuiStatus: () => 'idle' | 'busy' - acquireNative: (input: { - sessionId: string - fence: number - spawnToken: string - }) => Promise - acquireNativeStop: (turnId: string) => Promise - takeImportFailure: () => Error | null - statuses: AgentSessionHandoffStatus[] -} - -export function createStructuredAgentSessionHandoffTestCoordinator( - input: TestCoordinatorInput -): StructuredAgentSessionHandoffCoordinator { - return new StructuredAgentSessionHandoffCoordinator({ - store: input.store, - claimKeyId: 'key-1', - transport: { - hostLabel: 'Test host', - launchTui: input.launchTui, - reproveTuiOwner: input.reproveTuiOwner, - recoverTuiOwner: input.recoverTuiOwner, - stopRecoveredOwner: input.stopRecoveredOwner, - closeTuiOwner: input.closeTuiOwner, - waitForTuiExit: input.waitForTuiExit, - waitForTuiIdleOrExit: input.waitForTuiIdleOrExit, - tuiStatus: input.tuiStatus, - stopFailedTuiLaunch: input.stopFailedTuiLaunch - }, - session: () => ({ - journal: input.journal, - fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1 - }), - suspendNative: async () => ({ state: 'stopped' as const }), - acquireNative: input.acquireNative, - acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId), - importTuiHistory: async ({ fence }) => { - const importFailure = input.takeImportFailure() - if (importFailure) { - throw importFailure - } - await input.journal.appendItem( - input.provider === 'claude' - ? { provider: 'claude', sessionId: input.claudeSessionId, uuid: 'tui-turn' } - : { provider: 'codex', threadId: input.codexThreadId, turnId: 'tui-turn', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, - { fence, recovered: true } - ) - }, - retryPendingSettlement: async () => true, - publish: (_sessionId, status) => input.statuses.push(status), - schedule: async (_sessionId, task) => task(), - now: () => input.now - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts deleted file mode 100644 index ca33e486999d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type StructuredHandoffProviderCase = { - provider: 'claude' | 'codex' - accountHome: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; pathName: string } -} - -export const STRUCTURED_HANDOFF_PROVIDER_CASES: StructuredHandoffProviderCase[] = [ - { provider: 'codex', accountHome: { variable: 'CODEX_HOME', pathName: 'codex-home' } }, - { - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', pathName: 'claude-home' } - } -] - -export function structuredHandoffTestProcess(now: number, spawnToken: string, pid: number) { - return { hostId: 'local', pid, processStartTimeMs: now - 1_000, spawnToken } -} - -export function structuredHandoffTestLink(input: { - provider: 'claude' | 'codex' - fence: number - id: string - now: number - claudeSessionId: string - codexThreadId: string -}) { - return { - linkId: input.id, - handle: - input.provider === 'claude' - ? ({ - provider: 'claude' as const, - sessionId: input.claudeSessionId, - leafUuid: input.id.startsWith('native-link') ? 'tui-exit-leaf' : 'current-leaf' - } as const) - : ({ provider: 'codex' as const, threadId: input.codexThreadId } as const), - origin: 'resumed' as const, - mintedAtFence: input.fence, - observedAt: input.now - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts deleted file mode 100644 index 550ebded0daf..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionHandoffDirection, - AgentSessionHandoffAction, - AgentSessionHandoffMode, - AgentSessionHandoffRequest -} from '../../../shared/agent-session-wire' - -export type StructuredHandoffTestRequestOptions = { - action?: AgentSessionHandoffAction - operationId?: string -} - -export class StructuredHandoffTestRequests { - private operations = 0 - - constructor( - private readonly now: number, - private readonly sessionId: string, - private readonly readFence: () => number - ) {} - - reset(): void { - this.operations = 0 - } - - operationId(): string { - this.operations += 1 - return `${this.now}-${this.operations.toString(16).padStart(32, '0')}` - } - - request( - direction: AgentSessionHandoffDirection, - mode: AgentSessionHandoffMode, - options: StructuredHandoffTestRequestOptions = {} - ): AgentSessionHandoffRequest { - const action = options.action ?? 'start' - const fields = { direction, mode, action } - return { - envelope: { - sessionId: this.sessionId, - clientOperationId: options.operationId ?? this.operationId(), - expectedRuntimeFence: this.readFence(), - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.requestHandoff', - sessionId: this.sessionId, - fields - }) - }, - ...fields - } - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts deleted file mode 100644 index c1115f33a2bb..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' -import type { - AgentSessionProcessIdentity, - AgentSessionRecord -} from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' - -export type StructuredTuiOwner = { - terminal: { handle: string; tabId: string; paneKey: string; ptyId: string } - process: AgentSessionProcessIdentity - link: AgentSessionProviderHandleLink - transcriptPath?: string - /** Codex app-server resume, not row-by-row legacy import, restores this owner's history. */ - historySource?: 'provider-resume' - /** This owner came from an existing terminal view rather than a structured-session tab. */ - adoptedTerminal?: true -} - -export class StructuredTuiLaunchCleanupError extends Error { - constructor( - launchError: unknown, - readonly cleanupError: unknown - ) { - super('The failed terminal launch could not be proven stopped.', { cause: launchError }) - this.name = 'StructuredTuiLaunchCleanupError' - } -} - -export class StructuredTuiCatchupStoppedError extends Error { - constructor() { - super('TUI transcript catchup was stopped.') - this.name = 'StructuredTuiCatchupStoppedError' - } -} - -export type StructuredAgentSessionHandoffTransport = { - hostLabel: string - launchTui(input: { - record: AgentSessionRecord - fence: number - spawnToken: string - onSpawned?: (owner: StructuredTuiOwner) => Promise - }): Promise - reproveTuiOwner(input: { - record: AgentSessionRecord - owner: StructuredTuiOwner - }): Promise - recoverTuiOwner(record: AgentSessionRecord): Promise - probeRecoveredOwner?(record: AgentSessionRecord): Promise<'live' | 'dead' | 'unknown'> - stopRecoveredOwner(record: AgentSessionRecord): Promise - closeTuiOwner?(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> - revealNativeSession?(input: { - workspaceId: string - sessionId: string - agent?: 'claude' | 'codex' - adoptedTerminal?: true - }): Promise | void - waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> - waitForTuiIdleOrExit( - owner: StructuredTuiOwner, - signal: AbortSignal - ): Promise<'idle' | 'exited' | null> - tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' - stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise -} - -export type StructuredNativeSuspendResult = - | { state: 'live' } - | { state: 'stopped' } - | { state: 'stopped-cleanup-failed'; error: unknown } - -export type StructuredAgentSessionHandoffDeps = { - store: AgentSessionRecordStore - claimKeyId: string - transport?: StructuredAgentSessionHandoffTransport - session: (sessionId: string) => { journal: AgentSessionJournal; fence: number } - suspendNative: (sessionId: string) => Promise - /** Consumes the router's stop proof after `old-owner-stopped` is durable. */ - acknowledgeNativeRelease?: (sessionId: string) => void - acquireNative: (input: { - sessionId: string - fence: number - spawnToken: string - }) => Promise - acquireNativeStop?: (sessionId: string, turnId: string, fence: number) => Promise - importTuiHistory: (input: { - sessionId: string - fence: number - transcriptPath?: string - }) => Promise - retryPendingSettlement: (sessionId: string) => Promise - prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise - recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise - activateTuiHistoryCatchup?: (sessionId: string) => Promise - stopTuiHistoryCatchup?: (sessionId: string) => void - publish: (sessionId: string, status: AgentSessionHandoffStatus) => void - schedule: (sessionId: string, task: () => Promise) => Promise - now: () => number - /** Persist a provider handle observed while re-proving a TUI owner. */ - persistTuiProviderHandle?: (input: { - sessionId: string - link: AgentSessionProviderHandleLink - now: number - }) => Promise -} - -export type StructuredAgentSessionHandoffFlowContext = { - deps: StructuredAgentSessionHandoffDeps - owner: (sessionId: string) => StructuredTuiOwner | undefined - retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void - releaseOwner: (sessionId: string) => void - setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void - enterPreparing: ( - record: AgentSessionRecord, - operationId: string, - direction: 'to-tui' | 'to-native' - ) => Promise - publishStage: (record: AgentSessionRecord, direction: 'to-tui' | 'to-native') => void - requireRecord: (sessionId: string) => AgentSessionRecord -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts deleted file mode 100644 index 3c6277601af6..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffStatus -} from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { - reserveStoredAgentSessionHandoffOwner, - setStoredAgentSessionHandoffStage, - stopStoredAgentSessionOwnerForHandoff -} from '../../runtime/agent-session-handoff-record-transitions' -import type { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' -import { createStructuredHandoffFlowContext } from './structured-agent-session-handoff-flow-context' -import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' -import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' - -const journals = createTrackedJournalOpener() - -const NOW = 1_800_000_000_000 -const SESSION = 'session-handoff' -const PLAIN_RESIDUE = 'session-plain-residue' -const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -let root: string -let store: AgentSessionRecordStore -let journal: Awaited> -let coordinator: StructuredAgentSessionHandoffCoordinator -let statuses: AgentSessionHandoffStatus[] -type TransportMock = ReturnType< - typeof vi.fn< - Extract, (...args: never[]) => unknown> - > -> -let launchTui: TransportMock<'launchTui'> -let waitForTuiExit: TransportMock<'waitForTuiExit'> -let closeTuiOwner: TransportMock<'closeTuiOwner'> -let waitForTuiIdleOrExit: TransportMock<'waitForTuiIdleOrExit'> -let reproveTuiOwner: TransportMock<'reproveTuiOwner'> -let stopFailedTuiLaunch: TransportMock<'stopFailedTuiLaunch'> -let acquireNativeStop: ReturnType Promise>> -let acquireNativeCalls: number -let stopRecoveredOwner: TransportMock<'stopRecoveredOwner'> -let operations: number -type HistoryCatchup = (sessionId: string, fence: number) => Promise -let prepareTuiHistoryCatchup: ReturnType> -let recoverTuiHistoryCatchup: ReturnType> -let activateTuiHistoryCatchup: ReturnType Promise>> -let stopTuiHistoryCatchup: ReturnType void>> - -function operationId(): string { - operations += 1 - return `${NOW}-${operations.toString(16).padStart(32, '0')}` -} - -function process(spawnToken: string, pid: number) { - return { - hostId: 'local', - pid, - processStartTimeMs: NOW - 1_000, - spawnToken - } -} - -function link(fence: number, id: string) { - return { - linkId: id, - handle: { provider: 'codex' as const, threadId: THREAD }, - origin: 'resumed' as const, - mintedAtFence: fence, - observedAt: NOW - } -} - -async function establishNativeOwner(): Promise { - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', - expectedFence: null, - spawnToken: 'native-initial', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'initial' }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: process('native-initial', 4100), - now: NOW - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { ...link(fence, 'initial-link'), origin: 'created' }, - now: NOW - }) -} - -function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { - return { - terminal: { - handle: 'term-tui', - tabId: 'tab-tui', - paneKey: 'tab-tui:leaf-tui', - ptyId: 'pty-tui' - }, - process: process(spawnToken, 4200), - link: link(fence, `tui-link-${fence}`), - transcriptPath: join(root, 'rollout.jsonl') - } -} - -function createCoordinator(): StructuredAgentSessionHandoffCoordinator { - return new StructuredAgentSessionHandoffCoordinator({ - store, - claimKeyId: 'key-1', - transport: { - hostLabel: 'Test host', - launchTui, - reproveTuiOwner, - recoverTuiOwner: async (record) => { - const owner = makeTuiOwner( - record.lease.runtimeFence, - record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' - ) - return { ...owner, process: record.lease.ownerProcess ?? owner.process } - }, - probeRecoveredOwner: async () => 'dead', - stopRecoveredOwner, - closeTuiOwner, - waitForTuiExit, - waitForTuiIdleOrExit, - tuiStatus: () => 'idle', - stopFailedTuiLaunch - }, - session: () => ({ - journal, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - }), - suspendNative: vi.fn(async () => ({ state: 'stopped' as const })), - acquireNative: async (input) => { - acquireNativeCalls += 1 - await store.commitProcessIdentity({ - sessionId: input.sessionId, - fence: input.fence, - process: process(input.spawnToken, 4300 + acquireNativeCalls), - now: NOW - }) - return store.proveOwner({ - sessionId: input.sessionId, - fence: input.fence, - link: link(input.fence, `native-link-${input.fence}`), - now: NOW - }) - }, - acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId), - importTuiHistory: async ({ fence }) => { - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'tui-turn', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, - { fence, recovered: true } - ) - }, - retryPendingSettlement: async () => true, - prepareTuiHistoryCatchup, - recoverTuiHistoryCatchup, - activateTuiHistoryCatchup, - stopTuiHistoryCatchup, - publish: (_sessionId, status) => statuses.push(status), - schedule: async (_sessionId, task) => task(), - now: () => NOW - }) -} - -function request(operation: string): AgentSessionHandoffRequest { - return { - envelope: { - sessionId: SESSION, - clientOperationId: operation, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: 'test-handoff' - }, - direction: 'to-tui', - mode: 'now' - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-handoff-')) - operations = 0 - statuses = [] - acquireNativeCalls = 0 - prepareTuiHistoryCatchup = vi.fn(async () => undefined) - recoverTuiHistoryCatchup = vi.fn(async () => undefined) - activateTuiHistoryCatchup = vi.fn(async () => undefined) - stopTuiHistoryCatchup = vi.fn() - stopRecoveredOwner = vi.fn(async () => undefined) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - await establishNativeOwner() - journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: join(root, 'journal') - }) - launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) - waitForTuiExit = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) - closeTuiOwner = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) - waitForTuiIdleOrExit = vi.fn(async () => 'idle') - reproveTuiOwner = vi.fn(async ({ owner }) => owner) - stopFailedTuiLaunch = vi.fn(async () => undefined) - acquireNativeStop = vi.fn(async () => true) - coordinator = createCoordinator() -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('structured session handoff failure handling', () => { - it('parks a stopped native cleanup failure in manual recovery without launching TUI', async () => { - const operation = operationId() - const cleanupError = new Error('journal drain failed') - const acknowledgeNativeRelease = vi.fn((sessionId: string) => { - expect(store.getRecord(sessionId)?.lease.handoffStage).toBe('old-owner-stopped') - }) - const retainOwner = vi.fn() - const releaseOwner = vi.fn() - const context = createStructuredHandoffFlowContext({ - deps: { - store, - claimKeyId: 'key-1', - transport: { - hostLabel: 'Test host', - launchTui, - reproveTuiOwner, - recoverTuiOwner: async (record) => makeTuiOwner(record.lease.runtimeFence, 'recovered'), - stopRecoveredOwner, - closeTuiOwner, - waitForTuiExit, - waitForTuiIdleOrExit, - tuiStatus: () => 'idle', - stopFailedTuiLaunch - }, - session: () => ({ - journal, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - }), - suspendNative: vi.fn(async () => ({ - state: 'stopped-cleanup-failed' as const, - error: cleanupError - })), - acknowledgeNativeRelease, - acquireNative: vi.fn(async () => { - throw new Error('native acquisition should not run') - }), - acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId), - importTuiHistory: vi.fn(async () => undefined), - retryPendingSettlement: vi.fn(async () => true), - prepareTuiHistoryCatchup, - recoverTuiHistoryCatchup, - activateTuiHistoryCatchup, - stopTuiHistoryCatchup, - publish: (_sessionId, status) => statuses.push(status), - schedule: async (_sessionId, task) => task(), - now: () => NOW - }, - owner: () => undefined, - retainOwner, - releaseOwner, - setStatus: (_sessionId, status) => statuses.push(status), - requireRecord: (sessionId) => { - const record = store.getRecord(sessionId) - if (!record) { - throw new Error('missing record') - } - return record - } - }) - - await expect(handoffStructuredSessionToTui(context, request(operation), false)).rejects.toBe( - cleanupError - ) - - expect(launchTui).not.toHaveBeenCalled() - expect(retainOwner).not.toHaveBeenCalled() - expect(releaseOwner).not.toHaveBeenCalled() - expect(acknowledgeNativeRelease).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - claimStatus: 'released', - handoffStage: 'manual-recovery', - handoffOperationId: operation, - ownerProcess: null - }) - }) - it('settles cancellation after a TUI launch returns without retaining the new owner', async () => { - const operation = operationId() - const controller = new AbortController() - const launchEntered = Promise.withResolvers() - const launchRelease = Promise.withResolvers() - prepareTuiHistoryCatchup.mockResolvedValueOnce(controller.signal) - launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => { - launchEntered.resolve() - await launchRelease.promise - return makeTuiOwner(fence, spawnToken) - }) - - await setStoredAgentSessionHandoffStage(store, { - sessionId: SESSION, - fence: 1, - stage: 'preparing', - handoffOperationId: operation, - now: NOW - }) - await store.admitOperation({ - callerKey: 'test', - operationId: operation, - fingerprint: 'late-launch', - now: NOW - }) - const pending = coordinator.restore(SESSION) - const rejection = expect(pending).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) - await launchEntered.promise - const acquisitionsBeforeCancellation = acquireNativeCalls - controller.abort(new StructuredTuiCatchupStoppedError()) - launchRelease.resolve() - await rejection - - expect(stopFailedTuiLaunch).toHaveBeenCalledOnce() - expect(acquireNativeCalls).toBe(acquisitionsBeforeCancellation) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - claimStatus: 'released', - handoffStage: 'old-owner-stopped', - ownerProcess: null - }) - expect(store.listOperationRows().find((row) => row.operationId === operation)?.outcome).toEqual( - { - status: 'failed', - code: 'agent_session_handoff_failed' - } - ) - }) -}) - -// The direction-agnostic restore path is the crash-during-acquisition recovery every -// plain direct launch depends on: restart adjudication parks a crashed acquire at a -// handoff stage, and restore() is what un-strands it. The interactive handoff request -// flow itself is deliberately absent from this build. -describe('structured session ownership recovery on restore', () => { - it('continues a persisted preparing stage after restart instead of stranding it', async () => { - const operation = operationId() - await setStoredAgentSessionHandoffStage(store, { - sessionId: SESSION, - fence: 1, - stage: 'preparing', - handoffOperationId: operation, - now: NOW - }) - coordinator = createCoordinator() - - await coordinator.restore(SESSION) - - expect(stopRecoveredOwner).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: null - }) - }) - - it('finishes a live new-owner-proving stage after restart', async () => { - const operation = operationId() - let record = await setStoredAgentSessionHandoffStage(store, { - sessionId: SESSION, - fence: 1, - stage: 'preparing', - handoffOperationId: operation, - now: NOW - }) - record = await stopStoredAgentSessionOwnerForHandoff(store, { - sessionId: SESSION, - expectedFence: record.lease.runtimeFence, - operationId: operation, - now: NOW - }) - const spawnToken = 'restarted-tui' - record = await reserveStoredAgentSessionHandoffOwner(store, { - sessionId: SESSION, - expectedFence: record.lease.runtimeFence, - runtimeKind: 'tui', - spawnToken, - operationId: operation, - claimKeyId: 'key-1', - now: NOW - }) - await store.commitProcessIdentity({ - sessionId: SESSION, - fence: record.lease.runtimeFence, - process: process(spawnToken, 4400), - now: NOW - }) - coordinator = createCoordinator() - - await coordinator.restore(SESSION) - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: null - }) - expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) - expect(recoverTuiHistoryCatchup).toHaveBeenCalledWith( - SESSION, - store.getRecord(SESSION)?.lease.runtimeFence - ) - }) - - it('settles the interrupted recovery operation after catchup cancellation', async () => { - const operation = operationId() - let record = await setStoredAgentSessionHandoffStage(store, { - sessionId: SESSION, - fence: 1, - stage: 'preparing', - handoffOperationId: operation, - now: NOW - }) - record = await stopStoredAgentSessionOwnerForHandoff(store, { - sessionId: SESSION, - expectedFence: record.lease.runtimeFence, - operationId: operation, - now: NOW - }) - record = await reserveStoredAgentSessionHandoffOwner(store, { - sessionId: SESSION, - expectedFence: record.lease.runtimeFence, - runtimeKind: 'tui', - spawnToken: 'recovery-tui', - operationId: operation, - claimKeyId: 'key-1', - now: NOW - }) - await store.commitProcessIdentity({ - sessionId: SESSION, - fence: record.lease.runtimeFence, - process: process('recovery-tui', 4401), - now: NOW - }) - await store.admitOperation({ - callerKey: 'test', - operationId: operation, - fingerprint: 'recovery', - now: NOW - }) - const controller = new AbortController() - recoverTuiHistoryCatchup.mockResolvedValueOnce(controller.signal) - activateTuiHistoryCatchup.mockImplementationOnce(async () => { - controller.abort(new StructuredTuiCatchupStoppedError()) - }) - coordinator = createCoordinator() - - await expect(coordinator.restore(SESSION)).rejects.toBeInstanceOf( - StructuredTuiCatchupStoppedError - ) - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: null, - handoffOperationId: null, - ownerProcess: expect.any(Object) - }) - expect(store.listOperationRows().find((row) => row.operationId === operation)?.outcome).toEqual( - { - status: 'failed', - code: 'agent_session_handoff_failed' - } - ) - }) - - it('continues only the persisted TUI handoff after a store restart', async () => { - const plainOperation = operationId() - await store.reserveOwner({ - sessionId: PLAIN_RESIDUE, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', - expectedFence: null, - spawnToken: 'plain-residue-token', - claimKeyId: 'key-1', - handoffOperationId: plainOperation, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: plainOperation, fingerprint: 'plain-attach' }, - now: NOW - }) - const handoffOperation = operationId() - let interrupted = await setStoredAgentSessionHandoffStage(store, { - sessionId: SESSION, - fence: 1, - stage: 'preparing', - handoffOperationId: handoffOperation, - now: NOW - }) - interrupted = await stopStoredAgentSessionOwnerForHandoff(store, { - sessionId: SESSION, - expectedFence: interrupted.lease.runtimeFence, - operationId: handoffOperation, - now: NOW - }) - const interruptedFence = interrupted.lease.runtimeFence - - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - await store.reconcileOnRestart({ - probe: async (record) => - record.sessionId === PLAIN_RESIDUE - ? { outcome: 'indeterminate', reason: 'plain reservation cannot be attributed' } - : { outcome: 'pid-absent' }, - now: NOW + 1_000 - }) - journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: join(root, 'journal') - }) - launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) - coordinator = createCoordinator() - - await coordinator.restore(PLAIN_RESIDUE) - expect(launchTui).not.toHaveBeenCalled() - expect(acquireNativeCalls).toBe(0) - expect(store.getRecord(PLAIN_RESIDUE)?.lease).toMatchObject({ - runtimeKind: 'native', - runtimeFence: 1, - handoffStage: 'manual-recovery', - claimStatus: 'reserved', - reservedSpawnToken: 'plain-residue-token' - }) - expect( - store.listOperationRows().find((row) => row.operationId === plainOperation)?.outcome - ).toMatchObject({ status: 'failed', code: 'agent_session_ownership_unknown' }) - - await coordinator.restore(SESSION) - - expect(launchTui).toHaveBeenCalledOnce() - expect(acquireNativeCalls).toBe(1) - expect(launchTui.mock.calls[0]?.[0]).toMatchObject({ - record: { - sessionId: SESSION, - lease: { handoffOperationId: handoffOperation } - }, - fence: interruptedFence + 3 - }) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - runtimeFence: interruptedFence + 3, - handoffStage: null, - handoffOperationId: null, - claimStatus: 'live' - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts deleted file mode 100644 index 57333d1c90c0..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ /dev/null @@ -1,286 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffResult, - AgentSessionHandoffStatus, - AgentSessionMutationResult, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import { - admitStructuredHandoffRequest, - refuseAdmittedStructuredHandoff, - replayedStructuredHandoffRefusal, - structuredHandoffRetryIsAdmissible -} from './structured-agent-session-handoff-admission' -import { - createStructuredHandoffFlowContext, - requireStructuredHandoffRecord -} from './structured-agent-session-handoff-flow-context' -import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' -import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' -import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' -import { queueStructuredHandoffAfterTurn } from './structured-agent-session-handoff-queue-start' -import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' -import { requestStructuredManualRecovery } from './structured-agent-session-handoff-recover' -import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' -import { - structuredHandoffRefusal as refusal, - structuredHandoffSuccess -} from './structured-agent-session-handoff-result' -import { - failedStructuredHandoffStatus, - idleStructuredHandoffStatus, - structuredSessionHasPendingPrompt, - structuredTuiStatus -} from './structured-agent-session-handoff-status' -import type { - StructuredAgentSessionHandoffDeps, - StructuredAgentSessionHandoffFlowContext -} from './structured-agent-session-handoff-types' -import { StructuredAgentSessionHandoffState } from './structured-agent-session-handoff-state' -export class StructuredAgentSessionHandoffCoordinator { - private readonly state: StructuredAgentSessionHandoffState - private readonly queue = new StructuredAgentSessionHandoffQueue() - private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard - private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner - constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { - this.state = new StructuredAgentSessionHandoffState({ - requireRecord: (sessionId) => this.requireRecord(sessionId), - publish: deps.publish, - hostLabel: deps.transport?.hostLabel - }) - this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store) - this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({ - deps, - operationGuard: this.operationGuard, - flowContext: () => this.flowContext(), - fail: (params, error) => this.fail(params, error) - }) - } - status = (sessionId: string): AgentSessionHandoffStatus => this.state.status(sessionId) - drain = (): Promise => this.flowRunner.drain() - closeRetainedTuiOwner = (sessionId: string): Promise => - this.closeRetainedOwner(sessionId) - setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => - this.state.setStatus(sessionId, status) - async request( - callerKey: string, - params: AgentSessionHandoffRequest - ): Promise> { - const record = this.requireRecord(params.envelope.sessionId) - const currentStatus = this.state.cachedStatus(record.sessionId) - const admission = await admitStructuredHandoffRequest({ - deps: this.deps, - operationGuard: this.operationGuard, - callerKey, - params, - record, - ...(currentStatus ? { status: currentStatus } : {}) - }) - if (admission.decision === 'replay') { - const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome) - if (replayedRefusal) { - return { ok: false, refusal: replayedRefusal } - } - return this.success(record.sessionId, true) - } - if (admission.decision === 'refused') { - return { ok: false, refusal: admission.refusal } - } - const { fingerprint } = admission - const action = params.action ?? 'start' - if (action === 'cancel-queued') { - if (currentStatus?.phase !== 'queued' || currentStatus?.direction !== params.direction) { - return this.refuseAdmitted( - callerKey, - params, - 'agent_session_operation_conflict', - 'No matching queued handoff exists.' - ) - } - this.queue.cancel(record.sessionId) - this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) - await this.deps.store.recordOperationOutcome({ - callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'succeeded', sessionId: record.sessionId } - }) - return this.success(record.sessionId, false) - } - if (!this.deps.transport) { - return this.refuseAdmitted( - callerKey, - params, - 'structured_agent_session_unsupported', - 'Agent TUI handoff is unavailable on this host.' - ) - } - if (action === 'recover') { - const status = this.status(record.sessionId) - const started = await requestStructuredManualRecovery({ - deps: this.deps, - operationGuard: this.operationGuard, - callerKey, - params, - fingerprint, - record, - status, - requireRecord: this.requireRecord, - restore: this.restore, - setStatus: this.setStatus - }) - if (!started) { - return this.refuseAdmitted( - callerKey, - params, - 'agent_session_operation_conflict', - 'This handoff is no longer eligible for proof recovery.' - ) - } - return this.success(record.sessionId, false) - } - if (action === 'retry') { - if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { - return this.refuseAdmitted( - callerKey, - params, - 'agent_session_operation_conflict', - 'This handoff is no longer retryable.' - ) - } - this.begin(callerKey, params, null, fingerprint) - return this.success(record.sessionId, false) - } - const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' - if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') { - return this.refuseAdmitted( - callerKey, - params, - 'agent_session_conflict', - `The ${expectedOwner} runtime does not own this session.` - ) - } - if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) { - return this.refuseAdmitted( - callerKey, - params, - 'agent_session_conflict', - 'Resolve the pending question or approval before switching.' - ) - } - const turnId = activeStructuredAgentSessionTurnId( - this.deps.session(record.sessionId).journal.snapshot().items - ) - const tuiOwner = this.state.owner(record.sessionId) - const busy = - expectedOwner === 'native' - ? turnId !== null - : structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle' - if (busy && params.mode === 'now') { - return this.refuseAdmitted( - callerKey, - params, - 'agent_session_conflict', - 'The current turn must finish before switching.' - ) - } - if (busy && params.mode === 'after-turn') { - queueStructuredHandoffAfterTurn({ - callerKey, - params, - deps: this.deps, - queue: this.queue, - owner: (sessionId) => this.state.owner(sessionId), - setStatus: this.setStatus, - begin: (key, next, tuiAlreadyExited) => - this.begin(key, next, null, fingerprint, tuiAlreadyExited) - }) - return this.success(record.sessionId, false) - } - if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { - return this.refuseAdmitted( - callerKey, - params, - 'structured_agent_session_unsupported', - 'Exit the agent terminal after this turn to continue in chat.' - ) - } - this.begin(callerKey, params, turnId, fingerprint) - return this.success(record.sessionId, false) - } - async restore(sessionId: string): Promise { - await restoreStructuredAgentSessionHandoff( - { - deps: this.deps, - requireRecord: (id) => this.requireRecord(id), - flowContext: () => this.flowContext(), - retainOwner: (id, owner) => this.state.retainOwner(id, owner), - setStatus: (id, status) => this.state.setStatus(id, status) - }, - sessionId - ) - } - private refuseAdmitted( - callerKey: string, - params: AgentSessionHandoffRequest, - code: AgentSessionWireRefusal['code'], - message: string - ): Promise> { - return refuseAdmittedStructuredHandoff({ - deps: this.deps, - callerKey, - params, - refusal: refusal(code, message) - }) - } - private success( - sessionId: string, - replayed: boolean - ): AgentSessionMutationResult { - return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) - } - private begin( - callerKey: string, - params: AgentSessionHandoffRequest, - turnId: string | null, - fingerprint: string, - tuiAlreadyExited = false - ): void { - this.flowRunner.begin({ - callerKey, - params, - turnId, - fingerprint, - tuiAlreadyExited - }) - } - private flowContext(): StructuredAgentSessionHandoffFlowContext { - return createStructuredHandoffFlowContext({ - deps: this.deps, - owner: (sessionId) => this.state.owner(sessionId), - retainOwner: (sessionId, owner) => this.state.retainOwner(sessionId, owner), - releaseOwner: (sessionId) => this.state.releaseOwner(sessionId), - setStatus: (sessionId, status) => this.state.setStatus(sessionId, status), - requireRecord: (sessionId) => this.requireRecord(sessionId) - }) - } - private fail(params: AgentSessionHandoffRequest, error: unknown): void { - const record = this.requireRecord(params.envelope.sessionId) - this.setStatus( - record.sessionId, - failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel) - ) - } - private closeRetainedOwner(sessionId: string): Promise { - return closeRetainedTuiOwner({ - sessionId, - deps: this.deps, - owner: this.state.owner, - requireRecord: this.requireRecord, - releaseOwner: this.state.releaseOwner - }) - } - private requireRecord = (sessionId: string): AgentSessionRecord => - requireStructuredHandoffRecord(this.deps, sessionId) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff-stop.test.ts deleted file mode 100644 index f1dc020340e6..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff-stop.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// The handoff's own Stop bypasses performCancel, so it has to carry the same journal-derived -// live-turn read; without it this caller silently keeps judging against the adapter's copy. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { stopNativeHandoffTurn } from './structured-agent-session-host-handoff' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-session', leafUuid: null } -} - -const LIFECYCLE_IDENTITY = { - provider: 'legacy' as const, - agent: 'claude' as const, - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' -} - -let root: string | null = null -const journals = createTrackedJournalOpener() - -afterEach(async () => { - await journals.closeAll() - if (root) { - await rm(root, { recursive: true, force: true }) - root = null - } -}) - -describe('stopNativeHandoffTurn', () => { - it('judges its Stop against the turn the journal published', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-handoff-stop-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem( - LIFECYCLE_IDENTITY, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { fence: 3 } - ) - let resolveLiveTurnId: (() => string | null) | undefined - const cancelTurn = vi.fn( - async (input: Parameters[0]) => { - resolveLiveTurnId = input.resolveLiveTurnId - return { cancelled: true } - } - ) - - const stopped = await stopNativeHandoffTurn( - { cancelTurn }, - { journal }, - { - sessionId: 'session-1', - turnId: 'turn-1', - fence: 3 - } - ) - - expect(stopped).toBe(true) - expect(cancelTurn).toHaveBeenCalledOnce() - expect(resolveLiveTurnId?.()).toBe('turn-1') - // Re-read, not captured: the turn ending is what the guard has to see. - await journal.appendItem( - LIFECYCLE_IDENTITY, - { kind: 'status', text: 'Done.', turnLifecycle: { turnId: 'turn-1', state: 'completed' } }, - { fence: 3 } - ) - expect(resolveLiveTurnId?.()).toBeNull() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts deleted file mode 100644 index 55e186ba5232..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts +++ /dev/null @@ -1,602 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentSessionExecutionLocation, - AgentSessionRecord -} from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { - createDeferredStructuredAgentSessionEventSink, - type DeferredStructuredAgentSessionEventSink -} from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -import { - createStructuredAgentSessionHostHandoff, - structuredTuiTranscriptImportOptions -} from './structured-agent-session-host-handoff' -import { acquireNativeHandoffOwner } from './structured-agent-session-native-handoff-acquisition' -import { AgentSessionSubscribers } from './structured-agent-session-subscribers' - -const journals = createTrackedJournalOpener() - -function importRecord(provider: 'claude' | 'codex', accountHome: string): AgentSessionRecord { - return { - provider, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: accountHome - } - } as AgentSessionRecord -} - -/** The session's sink is `current`; a native acquire's attempt gets its own. */ -function sinksOver(current: DeferredStructuredAgentSessionEventSink) { - return { - eventSinkFor: () => current, - mintEventSink: () => createDeferredStructuredAgentSessionEventSink(), - adoptEventSink: () => undefined - } -} - -function unreachableSink(): never { - throw new Error('unreachable: publishing reads no sink') -} - -describe('structured TUI transcript import roots', () => { - it('uses the managed Claude account home when no live transcript path remains', () => { - expect(structuredTuiTranscriptImportOptions(importRecord('claude', '/managed/claude'))).toEqual( - { - claudeProjectsDir: join('/managed/claude', 'projects') - } - ) - }) - - it('uses the managed Codex account home when no live transcript path remains', () => { - expect(structuredTuiTranscriptImportOptions(importRecord('codex', '/managed/codex'))).toEqual({ - codexSessionsDirs: [join('/managed/codex', 'sessions')] - }) - }) -}) - -describe('native handoff acquisition', () => { - const sessionId = 'session-handoff-drain' - const threadId = 'thread-handoff-drain' - const now = 1_800_000_000_000 - let root: string - let store: AgentSessionRecordStore - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-native-handoff-')) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - }) - - afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) - }) - - it('drains queued rows before unbinding the old target and acquiring the native child', async () => { - const location: AgentSessionExecutionLocation = { - executionHostId: LOCAL_EXECUTION_HOST_ID, - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' as const - } - const reserved = await store.reserveOwner({ - sessionId, - location, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', - expectedFence: null, - spawnToken: 'native-handoff', - claimKeyId: 'key-1', - handoffOperationId: `${now}-00000000000000000000000000000001`, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'test', - operationId: `${now}-00000000000000000000000000000002`, - fingerprint: 'handoff' - }, - now - }) - const journal = await journals.open({ - identity: { - sessionId, - workspaceId: location.workspaceId, - hostId: location.executionHostId, - agent: 'codex', - providerHandle: { kind: 'codex', threadId } - }, - journalDir: join(root, 'journal') - }) - const eventSink = createDeferredStructuredAgentSessionEventSink() - const order: string[] = [] - const appendEntered = Promise.withResolvers() - const appendGate = Promise.withResolvers() - const originalAppend = journal.appendItem.bind(journal) - vi.spyOn(journal, 'appendItem').mockImplementationOnce(async (...args) => { - order.push('append-entered') - appendEntered.resolve() - await appendGate.promise - const result = await originalAppend(...args) - order.push('append-complete') - return result - }) - eventSink.bind({ - journal, - fence: reserved.record.lease.runtimeFence, - publish: () => undefined - }) - eventSink.sink.appendItem( - { provider: 'orca', clientMessageId: 'queued-before-handoff' }, - { kind: 'status', text: 'queued before handoff' } - ) - await appendEntered.promise - const originalUnbind = eventSink.unbind.bind(eventSink) - const unbind = vi.spyOn(eventSink, 'unbind').mockImplementation(() => { - order.push('unbind') - originalUnbind() - }) - const adapter = { - acquire: vi.fn(async ({ fence, spawnToken }) => { - order.push('acquire') - return { - process: { - hostId: 'local', - pid: 5300, - processStartTimeMs: now - 1_000, - spawnToken - }, - link: { - linkId: 'native-link', - handle: { provider: 'codex' as const, threadId }, - origin: 'created' as const, - mintedAtFence: fence, - observedAt: now - }, - acquisitionGeneration: 'generation-native' - } - }) - } - const session = { - journal, - params: { - envelope: { - sessionId, - clientOperationId: `${now}-00000000000000000000000000000003`, - expectedRuntimeFence: reserved.record.lease.runtimeFence, - payloadFingerprint: 'handoff' - }, - location, - provider: 'codex' as const, - agent: 'codex' as const, - accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId } - }, - fence: reserved.record.lease.runtimeFence, - hasProviderChild: false, - providerChildPhase: 'ready' as const, - acquisitionGeneration: null, - // Left by a restart before the handoff; a writer current as of it is not current now. - resumedFromFence: 1 - } - const acquiring = acquireNativeHandoffOwner( - { - store, - adapter: adapter as never, - journalRoot: root, - claimKeyId: 'key-1' - }, - { - session: () => session, - findSession: () => session, - eventSinks: sinksOver(eventSink), - flush: async () => undefined, - serialize: async (_session, task) => task(), - subscribers: { - publish: vi.fn(), - reset: vi.fn(), - handoff: vi.fn(), - snapshot: vi.fn() - } as never, - now: () => now - }, - { - sessionId, - fence: reserved.record.lease.runtimeFence, - spawnToken: 'native-handoff' - } - ) - await new Promise((resolve) => setImmediate(resolve)) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(unbind).not.toHaveBeenCalled() - - appendGate.resolve() - await acquiring - - expect(order).toEqual(['append-entered', 'append-complete', 'unbind', 'acquire']) - // The handoff moved the fence, not a restart: nothing is rebased across it. - expect(session.resumedFromFence).toBeUndefined() - }) - - it('refuses an unsupported adapter before unbinding the TUI owner', async () => { - const location: AgentSessionExecutionLocation = { - executionHostId: LOCAL_EXECUTION_HOST_ID, - wslDistro: null, - workspaceId: 'workspace-unsupported', - workspaceKind: 'folder' - } - const operationId = `${now}-00000000000000000000000000000011` - const reserved = await store.reserveOwner({ - sessionId: 'session-handoff-unsupported', - location, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', - expectedFence: null, - spawnToken: 'unsupported-spawn', - claimKeyId: 'key-1', - handoffOperationId: operationId, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId, fingerprint: 'unsupported' }, - now - }) - const journal = await journals.open({ - identity: { - sessionId: 'session-handoff-unsupported', - workspaceId: location.workspaceId, - hostId: location.executionHostId, - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'unsupported-thread' } - }, - journalDir: join(root, 'unsupported-journal') - }) - const eventSink = createDeferredStructuredAgentSessionEventSink() - eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined }) - const unbind = vi.spyOn(eventSink, 'unbind') - const acquire = vi.fn>() - const adapter = { - supportsLocation: vi.fn(() => false), - acquire - } - const session = { - journal, - params: { - envelope: { - sessionId: 'session-handoff-unsupported', - clientOperationId: `${now}-00000000000000000000000000000012`, - expectedRuntimeFence: reserved.record.lease.runtimeFence, - payloadFingerprint: 'unsupported' - }, - location, - provider: 'codex' as const, - agent: 'codex' as const, - accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId: 'unsupported-thread' } - }, - fence: reserved.record.lease.runtimeFence, - hasProviderChild: false, - providerChildPhase: 'ready' as const, - acquisitionGeneration: null - } - - await expect( - acquireNativeHandoffOwner( - { - store, - adapter: adapter as never, - journalRoot: root, - claimKeyId: 'key-1' - }, - { - session: () => session, - findSession: () => session, - eventSinks: sinksOver(eventSink), - flush: async () => undefined, - serialize: async (_sessionId, task) => task(), - subscribers: { - publish: vi.fn(), - reset: vi.fn(), - handoff: vi.fn(), - snapshot: vi.fn() - } as never, - now: () => now - }, - { - sessionId: 'session-handoff-unsupported', - fence: reserved.record.lease.runtimeFence, - spawnToken: 'unsupported-spawn' - } - ) - ).rejects.toThrow('structured_agent_session_unsupported') - expect(unbind).not.toHaveBeenCalled() - expect(acquire).not.toHaveBeenCalled() - }) - - it('rechecks adapter support immediately before handoff acquisition', async () => { - const sessionId = 'session-handoff-drift' - const location: AgentSessionExecutionLocation = { - executionHostId: LOCAL_EXECUTION_HOST_ID, - wslDistro: null, - workspaceId: 'workspace-drift', - workspaceKind: 'folder' - } - const operationId = `${now}-00000000000000000000000000000021` - const reserved = await store.reserveOwner({ - sessionId, - location, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', - expectedFence: null, - spawnToken: 'drift-spawn', - claimKeyId: 'key-1', - handoffOperationId: operationId, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId, fingerprint: 'drift' }, - now - }) - const journal = await journals.open({ - identity: { - sessionId, - workspaceId: location.workspaceId, - hostId: location.executionHostId, - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'drift-thread' } - }, - journalDir: join(root, 'drift-journal') - }) - const eventSink = createDeferredStructuredAgentSessionEventSink() - eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined }) - const unbind = vi.spyOn(eventSink, 'unbind') - const supportsLocation = vi.fn(() => true) - supportsLocation.mockReturnValueOnce(true).mockReturnValueOnce(false) - const acquire = vi.fn>() - const adapter = { supportsLocation, acquire } - const session = { - journal, - params: { - envelope: { - sessionId, - clientOperationId: `${now}-00000000000000000000000000000022`, - expectedRuntimeFence: reserved.record.lease.runtimeFence, - payloadFingerprint: 'drift' - }, - location, - provider: 'codex' as const, - agent: 'codex' as const, - accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId: 'drift-thread' } - }, - fence: reserved.record.lease.runtimeFence, - hasProviderChild: false, - providerChildPhase: 'ready' as const, - acquisitionGeneration: null - } - - await expect( - acquireNativeHandoffOwner( - { - store, - adapter: adapter as never, - journalRoot: root, - claimKeyId: 'key-1' - }, - { - session: () => session, - findSession: () => session, - eventSinks: sinksOver(eventSink), - flush: async () => undefined, - serialize: async (_sessionId, task) => task(), - subscribers: { - publish: vi.fn(), - reset: vi.fn(), - handoff: vi.fn(), - snapshot: vi.fn() - } as never, - now: () => now - }, - { sessionId, fence: reserved.record.lease.runtimeFence, spawnToken: 'drift-spawn' } - ) - ).rejects.toThrow('structured_agent_session_unsupported') - expect(supportsLocation).toHaveBeenCalledTimes(2) - expect(unbind).toHaveBeenCalledOnce() - expect(acquire).not.toHaveBeenCalled() - }) - - it("takes a failed native child's queued rows with it, leaving the session sink drainable", async () => { - const sessionId = 'session-handoff-failed' - const location: AgentSessionExecutionLocation = { - executionHostId: LOCAL_EXECUTION_HOST_ID, - wslDistro: null, - workspaceId: 'workspace-failed', - workspaceKind: 'folder' - } - const operationId = `${now}-00000000000000000000000000000031` - const reserved = await store.reserveOwner({ - sessionId, - location, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', - expectedFence: null, - spawnToken: 'failed-spawn', - claimKeyId: 'key-1', - handoffOperationId: operationId, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId, fingerprint: 'failed' }, - now - }) - const journal = await journals.open({ - identity: { - sessionId, - workspaceId: location.workspaceId, - hostId: location.executionHostId, - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'failed-thread' } - }, - journalDir: join(root, 'failed-journal') - }) - const current = createDeferredStructuredAgentSessionEventSink() - current.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined }) - const attempt = createDeferredStructuredAgentSessionEventSink() - const acquire = vi.fn>( - async ({ events }) => { - // The child wrote before it died, into whatever sink it was handed. - events?.setActivity?.(null) - throw new Error('codex app-server exited (code 1)') - } - ) - const session = { - journal, - params: { - envelope: { - sessionId, - clientOperationId: `${now}-00000000000000000000000000000032`, - expectedRuntimeFence: reserved.record.lease.runtimeFence, - payloadFingerprint: 'failed' - }, - location, - provider: 'codex' as const, - agent: 'codex' as const, - accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId: 'failed-thread' } - }, - fence: reserved.record.lease.runtimeFence, - hasProviderChild: false, - providerChildPhase: 'ready' as const, - acquisitionGeneration: null - } - - await expect( - acquireNativeHandoffOwner( - { - store, - adapter: { - acquire, - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalRoot: root, - claimKeyId: 'key-1' - }, - { - session: () => session, - findSession: () => session, - eventSinks: { ...sinksOver(current), mintEventSink: () => attempt }, - flush: async () => undefined, - serialize: async (_sessionId, task) => task(), - subscribers: new AgentSessionSubscribers(), - now: () => now - }, - { sessionId, fence: reserved.record.lease.runtimeFence, spawnToken: 'failed-spawn' } - ) - ).rejects.toThrow('codex app-server exited') - // The next attach drains the session's sink before acquiring; nothing may be stranded there. - expect(current.state().queuedOperations).toBe(0) - await expect(current.drained()).resolves.toEqual({ ok: true }) - expect(attempt.sink.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - }) -}) - -describe('handoff status published for a session the host no longer holds', () => { - const sessionId = 'session-handoff-publish-detached' - const now = 1_800_000_000_000 - const failed: AgentSessionHandoffStatus = { - owner: 'native', - direction: 'to-tui', - phase: 'failed', - stage: null, - operationId: null - } - let root: string - let store: AgentSessionRecordStore - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-handoff-publish-')) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - }) - - afterEach(async () => { - await rm(root, { recursive: true, force: true }) - }) - - function detachedHandoff(frames: { fence: number; status: AgentSessionHandoffStatus }[]) { - return createStructuredAgentSessionHostHandoff( - { store, adapter: {} as never, journalRoot: root, claimKeyId: 'key-1' }, - { - // Eviction and host teardown both drop the map entry while a flow is still settling. - session: () => { - throw new Error('agent_session_ownership_unknown') - }, - findSession: () => undefined, - eventSinks: { - eventSinkFor: unreachableSink, - mintEventSink: unreachableSink, - adoptEventSink: unreachableSink - }, - flush: async () => undefined, - serialize: async (_sessionId, task) => task(), - subscribers: { - publish: vi.fn(), - reset: vi.fn(), - snapshot: vi.fn(), - handoff: (_id: string, fence: number, status: AgentSessionHandoffStatus) => - void frames.push({ fence, status }) - } as never, - now: () => now - } - ) - } - - it('still reaches subscribers at the record fence instead of throwing', async () => { - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: LOCAL_EXECUTION_HOST_ID, - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', - expectedFence: null, - spawnToken: 'detached-publish', - claimKeyId: 'key-1', - handoffOperationId: `${now}-00000000000000000000000000000001`, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'test', - operationId: `${now}-00000000000000000000000000000002`, - fingerprint: 'handoff' - }, - now - }) - const frames: { fence: number; status: AgentSessionHandoffStatus }[] = [] - - expect(() => detachedHandoff(frames).setStatus(sessionId, failed)).not.toThrow() - - expect(frames).toEqual([{ fence: reserved.record.lease.runtimeFence, status: failed }]) - }) - - it('drops the publish when neither a session nor a record remains', () => { - const frames: { fence: number; status: AgentSessionHandoffStatus }[] = [] - - expect(() => detachedHandoff(frames).setStatus(sessionId, failed)).not.toThrow() - - expect(frames).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts deleted file mode 100644 index 41e2d8c61e3f..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { join } from 'node:path' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import' -import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart' -import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' -import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' -import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui-recovery' -import { acquireNativeHandoffOwner } from './structured-agent-session-native-handoff-acquisition' -import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' -import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' -import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' - -export type HostHandoffAccess = { - session: (sessionId: string) => StructuredAgentSessionHostSession - /** Non-throwing lookup, for the paths that only observe a detached session. */ - findSession: (sessionId: string) => StructuredAgentSessionHostSession | undefined - eventSinks: Pick< - StructuredAgentSessionHostRuntimeState, - 'eventSinkFor' | 'mintEventSink' | 'adoptEventSink' - > - flush: (sessionId: string) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - subscribers: AgentSessionSubscribers - publishStatus?: (sessionId: string) => void - now: () => number -} - -export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator & { - stopTuiHistoryCatchup: () => void - recoverDeadTuiOwner: ( - sessionId: string, - expectedFence: number, - probe: AgentSessionOwnerProbe - ) => Promise -} - -export async function refreshRecoverableStructuredHandoffStatus( - handoff: StructuredAgentSessionHostHandoff, - store: StructuredAgentSessionHostDeps['store'], - sessionId: string -) { - const record = store.getRecord(sessionId) - if (record && canRestoreLiveTuiOwner(record)) { - await handoff.restore(sessionId) - } - return handoff.status(sessionId) -} - -export function createStructuredAgentSessionHostHandoff( - deps: StructuredAgentSessionHostDeps, - host: HostHandoffAccess -): StructuredAgentSessionHostHandoff { - const tuiHistoryCatchup = new StructuredTuiTranscriptCatchup({ - store: deps.store, - session: host.session, - schedule: host.serialize, - publish: (sessionId) => { - const session = host.session(sessionId) - host.subscribers.publish(sessionId, session.journal) - }, - reset: (sessionId, fence) => { - const session = host.session(sessionId) - host.subscribers.reset(sessionId, session.journal, 'epoch_changed', fence) - }, - ...(deps.onEventSinkError ? { onError: deps.onEventSinkError } : {}) - }) - const coordinator = new StructuredAgentSessionHandoffCoordinator({ - store: deps.store, - claimKeyId: deps.claimKeyId, - ...(deps.handoffTransport ? { transport: deps.handoffTransport } : {}), - session: host.session, - suspendNative: async (sessionId) => { - if (!deps.adapter.closeSession) { - return { state: 'live' } - } - const exited = await deps.adapter.closeSession(sessionId) - if (exited !== true) { - // Report the unproven exit; the forward handoff refuses on it. - return { state: 'live' } - } - host.session(sessionId).hasProviderChild = false - host.publishStatus?.(sessionId) - try { - await host.flush(sessionId) - const session = host.session(sessionId) - await session.journal.markPendingSubmissionsUnknown( - session.fence, - 'provider_exited_before_acknowledgement' - ) - host.subscribers.publish(sessionId, session.journal) - host.publishStatus?.(sessionId) - host.eventSinks.eventSinkFor(sessionId).unbind() - return { state: 'stopped' } - } catch (error) { - return { state: 'stopped-cleanup-failed', error } - } - }, - acknowledgeNativeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId), - acquireNative: (input) => acquireNativeHandoffOwner(deps, host, input), - acquireNativeStop: (sessionId, turnId, fence) => - stopNativeHandoffTurn(deps.adapter, host.session(sessionId), { sessionId, turnId, fence }), - importTuiHistory: (input) => importTuiHistory(deps, host, input), - retryPendingSettlement: (sessionId) => - retryLoadedStructuredAgentSessionSettlement({ - deps, - sessionId, - session: host.session(sessionId), - now: host.now - }), - prepareTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.prepare(sessionId, fence), - recoverTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.recover(sessionId, fence), - activateTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.activate(sessionId), - stopTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.stop(sessionId), - publish: (sessionId, status) => { - // A status publish is a notification, not a mutation. Eviction and host teardown both drop - // the session while a handoff flow is still settling, and `requireSession` would turn that - // last publish — usually the FAILED one — into an unhandled rejection nothing can catch. - const fence = - deps.store.getRecord(sessionId)?.lease.runtimeFence ?? host.findSession(sessionId)?.fence - if (fence === undefined) { - return - } - host.subscribers.handoff(sessionId, fence, status) - }, - schedule: host.serialize, - now: host.now, - ...(deps.persistTuiProviderHandle - ? { persistTuiProviderHandle: deps.persistTuiProviderHandle } - : {}) - }) - return Object.assign(coordinator, { - stopTuiHistoryCatchup: () => tuiHistoryCatchup.stopAll(), - recoverDeadTuiOwner: async ( - sessionId: string, - expectedFence: number, - probe: AgentSessionOwnerProbe - ) => { - const record = deps.store.getRecord(sessionId) - if (!record) { - return - } - const status = await recoverDeadTuiHandoffStatus({ - store: deps.store, - now: host.now, - record, - expectedFence, - probe - }) - if (status) { - coordinator.setStatus(sessionId, status) - } - } - }) -} - -/** Handoff's own Stop, which never passes through `performCancel` and so has to carry the - * journal reads that judge a cancellation itself. */ -export async function stopNativeHandoffTurn( - adapter: Pick, - session: Pick, - input: { sessionId: string; turnId: string; fence: number } -): Promise { - const dispatchStatus = latestJournalDispatchObservation(session.journal, input.fence) - return ( - await adapter.cancelTurn({ - ...input, - // The journal is what the client read to name a turn, so it is what judges the request. - resolveLiveTurnId: () => session.journal.activeTurnId(), - ...(dispatchStatus ? { dispatchStatus } : {}) - }) - ).cancelled -} - -async function importTuiHistory( - deps: StructuredAgentSessionHostDeps, - host: HostHandoffAccess, - input: { sessionId: string; fence: number; transcriptPath?: string } -): Promise { - const session = host.session(input.sessionId) - const record = deps.store.getRecord(input.sessionId) - const head = record?.providerHandleChain.at(-1) - if (!record || !head) { - throw new Error('agent_session_identity_required') - } - const options = structuredTuiTranscriptImportOptions(record, input.transcriptPath) - const providerSessionId = - head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId - const imported = await importLegacyTranscriptIntoJournal({ - journal: session.journal, - agent: head.handle.provider, - sessionId: providerSessionId, - fence: input.fence, - options - }) - if (!imported.ok) { - throw new Error(imported.error) - } - host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence) -} - -export function structuredTuiTranscriptImportOptions( - record: AgentSessionRecord, - transcriptPath?: string -): LegacyImportOptions { - if (transcriptPath) { - return { filePath: transcriptPath } - } - return record.provider === 'claude' - ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } - : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts index 90d60dc501dc..8d54ef52784a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -130,12 +130,7 @@ describe('host runtime-state owner probe', () => { probeOwner, onEventSinkError } as unknown as StructuredAgentSessionHostDeps - const state = new StructuredAgentSessionHostRuntimeState( - deps, - undefined, - undefined, - onEventSinkFailure - ) + const state = new StructuredAgentSessionHostRuntimeState(deps, onEventSinkFailure) await ( state as unknown as { leaseRenewer: { renewNow: () => Promise } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 2b0042bee98a..39fe7c3fc22a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -16,8 +16,6 @@ export class StructuredAgentSessionHostRuntimeState { constructor( private readonly deps: StructuredAgentSessionHostDeps, - onLeaseRenewed?: (record: AgentSessionRecord) => Promise, - onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise, onEventSinkFailure?: (sessionId: string, error: unknown) => void ) { this.onEventSinkFailure = onEventSinkFailure @@ -26,8 +24,6 @@ export class StructuredAgentSessionHostRuntimeState { probe: (record) => this.probeRecord(record), ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), now: () => deps.now?.() ?? Date.now(), - ...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}), - ...(onDeadTuiOwner ? { onDeadTuiOwner } : {}), // Lease/ownership failures are transient and stay on the visible lease-error path. // Only deferred sink I/O failures are terminal and may force-close a provider. onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts new file mode 100644 index 000000000000..d04e17d2d26c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionHostTeardownPhases } from './structured-agent-session-host-teardown' + +describe('structured agent-session host teardown', () => { + it('names every phase, so the quit-path order is pinned rather than incidental', () => { + const noop = async (): Promise => undefined + const phases = structuredAgentSessionHostTeardownPhases({ + holds: { dispose: noop }, + runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + tasks: { drainAttaches: noop }, + evictOwnedSessions: noop, + beginResumeMarkers: () => {}, + recordResumeMarkers: noop + }) + expect(phases.map((phase) => phase.name)).toEqual([ + 'begin-resume-markers', + 'dispose-holds', + 'stop-lease-renewal', + 'drain-attaches', + 'evict-owned-sessions', + 'record-resume-markers', + 'flush-event-sinks' + ]) + }) + + it('bounds stalled recovery publication without preventing later cleanup', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const pending = Promise.withResolvers() + const cleaned = vi.fn(async () => {}) + const flush = vi.fn(async () => cleaned()) + const phases = structuredAgentSessionHostTeardownPhases({ + holds: { dispose: cleaned }, + runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, + tasks: { drainAttaches: cleaned }, + evictOwnedSessions: cleaned, + beginResumeMarkers: () => {}, + recordResumeMarkers: () => pending.promise + }) + try { + const teardown = (async () => { + for (const phase of phases) { + await phase.run() + } + })() + await vi.advanceTimersByTimeAsync(2000) + await teardown + expect(cleaned).toHaveBeenCalledTimes(4) + expect(warning).toHaveBeenCalledWith( + '[structured-agent-session] recording recovery capsule failed' + ) + expect(vi.getTimerCount()).toBe(0) + } finally { + pending.resolve() + warning.mockRestore() + vi.useRealTimers() + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index cc49ec3a7626..6b1693c3dca2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -21,9 +21,6 @@ export type StructuredAgentSessionTeardownPhase = { run: () => Promise | void } -/** Quit must not wait indefinitely on an in-flight handoff; see `drain-handoffs` below. */ -const HANDOFF_DRAIN_TIMEOUT_MS = 5_000 - /** Advisory persistence must not hold shutdown open. */ const RESUME_MARKER_RECORD_TIMEOUT_MS = 2_000 @@ -47,21 +44,13 @@ async function withPhaseTimeout(run: () => Promise, timeoutMs: number): Pr } } -/** - * The quit-path phase order, which is load-bearing rather than incidental. - * - * Handoffs drain BEFORE the session map is dropped: a flow left running writes rows into a - * journal this teardown is about to close, and publishes against a session it removed. That drain - * is bounded because a flow wedged in `launchTui` would otherwise hold the quit open forever; - * giving up merely restores the old orphaning, which the publish guard already makes survivable. - */ +/** The quit-path phase order, which is load-bearing rather than incidental. */ export function structuredAgentSessionHostTeardownPhases(collaborators: { holds: { dispose: () => Promise | void } runtimeState: { stopLeaseRenewal: () => void flushAllEventSinks: () => Promise } - handoffs: { stopTuiHistoryCatchup: () => void; drain: () => Promise } tasks: { drainAttaches: () => Promise } evictOwnedSessions: () => Promise /** Opens this teardown's witnesses; each session's own is taken as eviction stops its child. */ @@ -81,11 +70,6 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { }, { name: 'dispose-holds', run: () => collaborators.holds.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, - { name: 'stop-tui-catchup', run: () => collaborators.handoffs.stopTuiHistoryCatchup() }, - { - name: 'drain-handoffs', - run: () => withTimeout(collaborators.handoffs.drain(), HANDOFF_DRAIN_TIMEOUT_MS, undefined) - }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { name: 'evict-owned-sessions', @@ -149,10 +133,7 @@ export async function tearDownStructuredAgentSessionHost(input: { export async function flushStructuredAgentSessionHost( context: StructuredAgentSessionLifetimeContext & - Pick< - Parameters[0], - 'holds' | 'handoffs' | 'tasks' - > & { + Pick[0], 'holds' | 'tasks'> & { restartResume: StructuredAgentSessionRestartResume serialize: (sessionId: string, task: () => Promise) => Promise trigger: AgentSessionResumeTrigger diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 468ec57f0128..2ff77d4436d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,5 +1,4 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -11,7 +10,6 @@ import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' @@ -48,7 +46,7 @@ export type StructuredAgentSessionHostSession = { acquisitionGeneration: string | null /** The fence of the released owner this child replaced, when it was resumed into a lease handed * back cleanly. A writer current as of that owner is admitted at `fence`: the restart is the - * only thing that moved it. Absent for a create, a handoff, or a journal restored for reading. */ + * only thing that moved it. Absent for a create or a journal restored for reading. */ resumedFromFence?: number } @@ -75,11 +73,6 @@ export type StructuredAgentSessionHostDeps = { provider: AgentSessionRecord['provider'] ) => Promise | undefined> | Record | undefined now?: () => number - persistTuiProviderHandle?: (input: { - sessionId: string - link: AgentSessionProviderHandleLink - now: number - }) => Promise /** How long a session outlives its last surface. Tests drive this; production takes the default. */ releaseGraceMs?: number onEventSinkError?: (input: { sessionId: string; error: unknown }) => void @@ -93,7 +86,6 @@ export type StructuredAgentSessionHostDeps = { * removed from. Both production hosts pass one — the desktop and headless `orcad`; absent, * every reader of that store simply lists no structured session. */ statusSink?: StructuredAgentSessionStatusSink - handoffTransport?: StructuredAgentSessionHandoffTransport /** Host model catalog surface; absent means every catalog read answers `unknown`. */ modelCatalog?: AgentModelCatalogService } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 7426bcfba2ef..b980c4e422cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -706,7 +706,8 @@ describe('subscribe', () => { emit: (event) => events.push(event), cursor: first.cursor }) - expect(events[0]).toMatchObject({ type: 'batch', handoff: { owner: 'native', phase: 'idle' } }) + expect(events[0]).toMatchObject({ type: 'batch' }) + expect(events[0]).not.toHaveProperty('handoff') const second = hostTestMessage('and a timeout') await host.send(CALLER, { 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 533c86a61ce1..8f29c6ec322b 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 @@ -14,11 +14,7 @@ import type { AgentSessionSubscribeInput } from './structured-agent-session-subs import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' import { createStructuredAgentSessionHostRestore } from './structured-agent-session-reveal' -import { - createStructuredAgentSessionHostHandoff, - refreshRecoverableStructuredHandoffStatus, - type StructuredAgentSessionHostHandoff -} from './structured-agent-session-host-handoff' +import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import { @@ -75,7 +71,6 @@ export class StructuredAgentSessionHost { private readonly reconcileLeases: ( sessionId: string ) => Promise - private readonly handoffs: StructuredAgentSessionHostHandoff private readonly restore: ReturnType private readonly holds: StructuredAgentSessionHolds private readonly eventRecovery: StructuredAgentSessionEventRecovery @@ -89,19 +84,10 @@ export class StructuredAgentSessionHost { this.sessions, this.subscribers, (sessionId) => this.requireSession(sessionId), - (sessionId) => this.handoffs.status(sessionId), this.clientDelivery.publishStatus ) - this.runtimeState = new StructuredAgentSessionHostRuntimeState( - deps, - (record) => this.restoreRenewedHandoff(record.sessionId), - (record, probe) => - this.sessions.has(record.sessionId) - ? this.serialize(record.sessionId, () => - this.handoffs.recoverDeadTuiOwner(record.sessionId, record.lease.runtimeFence, probe) - ) - : Promise.resolve(), - (sessionId, error) => this.eventRecovery.recoverAfterSinkFailure(sessionId, error) + this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => + this.eventRecovery.recoverAfterSinkFailure(sessionId, error) ) this.reconcileLeases = createRestartReconciler({ store: deps.store, @@ -109,16 +95,6 @@ export class StructuredAgentSessionHost { ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), now: () => this.now() }) - this.handoffs = createStructuredAgentSessionHostHandoff(deps, { - session: (sessionId) => this.requireSession(sessionId), - findSession: (sessionId) => this.sessions.get(sessionId), - eventSinks: this.runtimeState, - flush: (sessionId) => this.flushStreamedEvents(sessionId), - serialize: (sessionId, task) => this.serialize(sessionId, task), - subscribers: this.subscribers, - publishStatus: this.clientDelivery.publishStatus, - now: this.now - }) this.holds = createStructuredAgentSessionHolds( () => this.attachContext(), (sessionId) => this.close(sessionId) @@ -133,8 +109,7 @@ export class StructuredAgentSessionHost { onReadable: (sessionId, restored) => { this.sessions.set(sessionId, restored) this.clientDelivery.publishRestored(sessionId) - }, - restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) + } }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, @@ -203,7 +178,6 @@ export class StructuredAgentSessionHost { * on disk, so the same session can be attached again. */ close(sessionId: string): Promise { return this.serialize(sessionId, async () => { - await this.handoffs.closeRetainedTuiOwner(sessionId) await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) this.clientDelivery.closeSession(sessionId) // The holders now look at a session that is gone; a failed eviction throws above, keeping them. @@ -236,14 +210,6 @@ export class StructuredAgentSessionHost { private serialize = this.tasks.serialize.bind(this.tasks) - private restoreRenewedHandoff(sessionId: string): Promise { - return this.serialize(sessionId, async () => { - if (this.sessions.has(sessionId)) { - await refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) - } - }) - } - attach( caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams @@ -260,7 +226,6 @@ export class StructuredAgentSessionHost { await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), holds: this.holds, - handoffs: this.handoffs, tasks: this.tasks, restartResume: this.restartResume, serialize: this.serialize, @@ -293,12 +258,6 @@ export class StructuredAgentSessionHost { changeThreadGoal = this.mutations.changeThreadGoal readOptions = this.mutations.readOptions - requestHandoff = ( - caller: StructuredAgentSessionCaller, - params: SessionWire.AgentSessionHandoffRequest - ): Promise> => - this.handoffs.request(caller.callerKey, params) - rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) => rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) @@ -312,9 +271,11 @@ export class StructuredAgentSessionHost { async handoffStatus(sessionId: string): Promise { this.requireSession(sessionId) - return this.serialize(sessionId, () => - refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) - ) + const record = this.deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return structuredAgentSessionOwnerStatus(record) } history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index f0540b2b225c..f8323d40fe11 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -144,7 +144,6 @@ describe('structured agent-session lease renewal', () => { } return records[0]! }) - const onRenewed = vi.fn() const onError = vi.fn() const renewer = new StructuredAgentSessionLeaseRenewer({ store: { @@ -157,16 +156,13 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['spawn-token' as const] }), now: () => NOW + 10_000, - onRenewed, onError }) await renewer.renewNow() expect(renewLeases).toHaveBeenCalledOnce() - expect(onRenewed).toHaveBeenCalledOnce() expect(renewLease).toHaveBeenCalledTimes(2) - expect(onRenewed).toHaveBeenCalledWith(records[0]) expect(onError).toHaveBeenCalledWith({ sessionId: 'session-b', error: expect.objectContaining({ message: 'agent_session_checkpoint_stale' }) @@ -206,21 +202,16 @@ describe('structured agent-session lease renewal', () => { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] })) - const onRenewed = vi.fn() const renewer = new StructuredAgentSessionLeaseRenewer({ store, probe, - now: () => NOW + 10_000, - onRenewed + now: () => NOW + 10_000 }) await renewer.renewNow() expect(probe).toHaveBeenCalledOnce() expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) - expect(onRenewed).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: 'session-renewal' }) - ) }) it('stops extending the lease when child proof is no longer sufficient', async () => { @@ -242,8 +233,8 @@ describe('structured agent-session lease renewal', () => { }) }) - it('never extends the lease of a native record parked in recovery', async () => { - // The host cannot vouch for a native child it holds no transport to; renewing while + it('never extends the lease of a record parked in recovery', async () => { + // The host cannot vouch for a child it holds no transport to; renewing while // recovering keeps an orphan pid's lease alive and reads as a healthy owner. const store = await liveStore() await store.transitionHandoff('session-renewal', (record) => ({ @@ -265,30 +256,4 @@ describe('structured agent-session lease renewal', () => { expect(probe).not.toHaveBeenCalled() expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) }) - - it('routes a proven dead TUI owner into handoff recovery', async () => { - const store = await liveStore() - await store.transitionHandoff('session-renewal', (record) => ({ - ...record, - lease: { ...record.lease, runtimeKind: 'tui' } - })) - const onDeadTuiOwner = vi.fn(async () => undefined) - const onError = vi.fn() - const renewer = new StructuredAgentSessionLeaseRenewer({ - store, - probe: async () => ({ outcome: 'pid-absent' }), - now: () => NOW + 10_000, - onDeadTuiOwner, - onError - }) - - await renewer.renewNow() - - expect(onDeadTuiOwner).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: 'session-renewal' }), - { outcome: 'pid-absent' } - ) - expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) - expect(onError).not.toHaveBeenCalled() - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index e31ea8c95a72..f1e5ca98305d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -1,7 +1,4 @@ -import { - isProvenDeadProbe, - type AgentSessionOwnerProbe -} from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AGENT_SESSION_LEASE_TTL_MS, @@ -22,8 +19,6 @@ export class StructuredAgentSessionLeaseRenewer { records: readonly AgentSessionRecord[] ) => Promise> now: () => number - onRenewed?: (record: AgentSessionRecord) => Promise - onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise onError?: (input: { sessionId: string; error: unknown }) => void intervalMs?: number } @@ -55,13 +50,10 @@ export class StructuredAgentSessionLeaseRenewer { !record.lease.unreconciled && record.lease.claimStatus === 'live' && record.lease.ownerProcess !== null && - // A native record parked in recovery has no transport the host can vouch - // for; renewing it keeps an orphan pid's lease reading as a healthy owner. - !( - record.lease.runtimeKind === 'native' && - (record.lease.handoffStage === 'recovering' || - record.lease.handoffStage === 'manual-recovery') - ) + // A record parked in recovery has no transport the host can vouch for; renewing it + // keeps an orphan pid's lease reading as a healthy owner. + record.lease.handoffStage !== 'recovering' && + record.lease.handoffStage !== 'manual-recovery' ) const probes = await this.probe(records) const renewals: { @@ -76,18 +68,6 @@ export class StructuredAgentSessionLeaseRenewer { if (!probe) { continue } - if ( - record.lease.runtimeKind === 'tui' && - isProvenDeadProbe(probe) && - this.input.onDeadTuiOwner - ) { - try { - await this.input.onDeadTuiOwner(record, probe) - } catch (error) { - this.input.onError?.({ sessionId: record.sessionId, error }) - } - continue - } renewals.push({ sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -100,19 +80,10 @@ export class StructuredAgentSessionLeaseRenewer { let results: PromiseSettledResult[] try { const renewed = await this.input.store.renewLeases(renewals) - await Promise.all( - renewed.map(async (record) => { - await this.input.onRenewed?.(record) - }) - ) results = renewed.map((record) => ({ status: 'fulfilled', value: record }) as const) } catch { results = await Promise.allSettled( - renewals.map(async (renewal) => { - const renewed = await this.input.store.renewLease(renewal) - await this.input.onRenewed?.(renewed) - return renewed - }) + renewals.map((renewal) => this.input.store.renewLease(renewal)) ) } results.forEach((result, index) => { 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 new file mode 100644 index 000000000000..d4def4833a8d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -0,0 +1,214 @@ +// Records an older build persisted mid terminal handoff. That build could leave a lease at +// `preparing` or `old-owner-stopped`, or owned by a terminal (`runtimeKind: 'tui'`). This build +// has no handoff to finish, so each state must still load and end in a chat the user can send to. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionLease } from '../../../shared/agent-session-record' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let probe: Mock<() => Promise> +let stopOwnerProcess: Mock<(pid: number, signal: 'SIGTERM' | 'SIGKILL') => void> + +function openHost(): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + now: () => NOW, + probeOwner: probe, + stopOwnerProcess + }) +} + +/** Writes the lease an older build left behind, then starts a fresh app generation over it. */ +async function persistFromOlderBuild(lease: Partial): Promise { + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, ...lease } + })) + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + acquire.mockClear() + openHost() +} + +async function send(text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-legacy-handoff-record-')) + resetHostTestOperationIds() + probe = vi.fn(async () => ({ outcome: 'pid-absent' as const })) + stopOwnerProcess = vi.fn() + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a record an older build left mid terminal handoff', () => { + it('loads a terminal-owned lease stopped mid return trip, and the chat accepts a send', async () => { + // The shape an older build wrote when its terminal owner died with a turn left to settle. + await persistFromOlderBuild({ + runtimeKind: 'tui', + runtimeFence: 2, + handoffStage: 'old-owner-stopped', + handoffOperationId: `${NOW}-handoff`, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence: { kind: 'pid-absent', detail: 'recorded pid absent on host', observedAt: NOW }, + settlementRetryRequired: true, + settlementRetryId: `restart-eviction:${SESSION}:2` + }) + expect(store.isSessionUnreadable(SESSION)).toBe(false) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('old-owner-stopped') + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + handoffOperationId: null, + settlementRetryRequired: undefined + }) + expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('loads a lease released for a terminal that never started, and the chat accepts a send', async () => { + // The shape an older build wrote when its terminal launch was abandoned. + await persistFromOlderBuild({ + runtimeFence: 2, + handoffStage: 'old-owner-stopped', + handoffOperationId: `${NOW}-handoff`, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence: { + kind: 'exit-observed', + detail: 'handoff launch attempt stopped', + observedAt: NOW + } + }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + handoffOperationId: null + }) + expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) + }) + + it('loads a chat owner quiesced for a handoff that never finished, and the chat accepts a send', async () => { + await persistFromOlderBuild({ handoffStage: 'preparing', handoffOperationId: `${NOW}-handoff` }) + expect(store.isSessionUnreadable(SESSION)).toBe(false) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + claimStatus: 'released' + }) + expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live' + }) + }) + + it('waits out a terminal owner that is still running, never stops it, then resumes the chat', async () => { + await persistFromOlderBuild({ runtimeKind: 'tui' }) + probe.mockResolvedValue({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + handoffStage: 'recovering' + }) + expect(await send('while the terminal still runs')).toMatchObject({ ok: false }) + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(acquire).not.toHaveBeenCalled() + + // The user closes the terminal; the next open proves it gone and the chat takes over. + probe.mockResolvedValue({ outcome: 'pid-absent' }) + await host.hold(SESSION, 'surface-1') + + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(await send('after the terminal closed')).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts deleted file mode 100644 index 3a8de86c3de0..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' -import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-live-tui-restart' -const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('structured session live TUI restart survival', () => { - it('does not stop a daemon-owned toggle TUI when restart adoption is unavailable', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-live-tui-restart-')) - roots.push(root) - const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'tui', - expectedFence: null, - spawnToken: 'toggle-tui-spawn', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'test', - operationId: `${NOW}-00000000000000000000000000000000`, - fingerprint: 'create' - }, - now: NOW - }) - const process = { - hostId: 'local', - pid: 4200, - processStartTimeMs: NOW - 1_000, - spawnToken: 'toggle-tui-spawn' - } - await store.commitProcessIdentity({ - sessionId: SESSION, - fence: reserved.record.lease.runtimeFence, - process, - now: NOW - }) - const record = await store.proveOwner({ - sessionId: SESSION, - fence: reserved.record.lease.runtimeFence, - link: { - linkId: 'toggle-tui-link', - handle: { provider: 'codex', threadId: THREAD }, - origin: 'created', - mintedAtFence: reserved.record.lease.runtimeFence, - observedAt: NOW - }, - now: NOW - }) - await setStoredAgentSessionHandoffStage(store, { - sessionId: SESSION, - fence: record.lease.runtimeFence, - stage: 'manual-recovery', - handoffOperationId: null, - now: NOW - }) - const stopRecoveredOwner = vi.fn(async () => undefined) - const coordinator = new StructuredAgentSessionHandoffCoordinator({ - store, - claimKeyId: 'key-1', - transport: { - hostLabel: 'Test host', - launchTui: vi.fn(), - recoverTuiOwner: vi.fn(async () => ({ - terminal: { - handle: 'term-toggle', - tabId: 'tab-toggle', - paneKey: 'tab-toggle:leaf-toggle', - ptyId: 'pty-toggle' - }, - process, - link: record.providerHandleChain.at(-1)! - })), - reproveTuiOwner: vi.fn(async () => { - throw new Error('The owning terminal is not hydrated yet.') - }), - probeRecoveredOwner: async () => 'live', - stopRecoveredOwner, - waitForTuiExit: vi.fn(), - waitForTuiIdleOrExit: vi.fn(), - tuiStatus: () => 'busy' - }, - session: vi.fn() as never, - suspendNative: vi.fn(), - acquireNative: vi.fn(), - importTuiHistory: vi.fn(), - retryPendingSettlement: vi.fn(async () => true), - publish: vi.fn(), - schedule: async (_sessionId, task) => task(), - now: () => NOW - }) - - await coordinator.restore(SESSION) - - expect(stopRecoveredOwner).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: 'manual-recovery', - ownerProcess: process - }) - }) - - it('persists the provider leaf returned by Claude re-proof before clearing recovery', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-restart-')) - roots.push(root) - const sessionId = 'session-claude-live-tui-restart' - const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - runtimeKind: 'tui', - expectedFence: null, - spawnToken: 'claude-tui-spawn', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'test', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'create' - }, - now: NOW - }) - const process = { - hostId: 'local', - pid: 4201, - processStartTimeMs: NOW - 1_000, - spawnToken: 'claude-tui-spawn' - } - await store.commitProcessIdentity({ - sessionId, - fence: reserved.record.lease.runtimeFence, - process, - now: NOW - }) - const record = await store.proveOwner({ - sessionId, - fence: reserved.record.lease.runtimeFence, - link: { - linkId: 'claude-created-link', - handle: { provider: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-before' }, - origin: 'created', - mintedAtFence: reserved.record.lease.runtimeFence, - observedAt: NOW - }, - now: NOW - }) - await setStoredAgentSessionHandoffStage(store, { - sessionId, - fence: record.lease.runtimeFence, - stage: 'manual-recovery', - handoffOperationId: null, - now: NOW - }) - - const reproofed = { - terminal: { - handle: 'term-claude', - tabId: 'tab-claude', - paneKey: 'tab-claude:leaf-claude', - ptyId: 'pty-claude' - }, - process, - link: { - linkId: 'claude-resumed-link', - handle: { - provider: 'claude' as const, - sessionId: 'claude-session', - leafUuid: 'leaf-after' - }, - origin: 'resumed' as const, - mintedAtFence: record.lease.runtimeFence, - observedAt: NOW + 1 - }, - transcriptPath: join(root, 'claude-home', 'projects', 'session.jsonl') - } - const persistTuiProviderHandle = vi.fn(async () => undefined) - const coordinator = new StructuredAgentSessionHandoffCoordinator({ - store, - claimKeyId: 'key-1', - transport: { - hostLabel: 'Test host', - launchTui: vi.fn(), - recoverTuiOwner: vi.fn(async () => ({ - ...reproofed, - link: record.providerHandleChain.at(-1)! - })), - reproveTuiOwner: vi.fn(async () => reproofed), - probeRecoveredOwner: async () => 'live', - stopRecoveredOwner: vi.fn(), - waitForTuiExit: vi.fn(), - waitForTuiIdleOrExit: vi.fn(), - tuiStatus: () => 'idle' - }, - persistTuiProviderHandle, - session: vi.fn() as never, - suspendNative: vi.fn(), - acquireNative: vi.fn(), - importTuiHistory: vi.fn(), - retryPendingSettlement: vi.fn(async () => true), - publish: vi.fn(), - schedule: async (_sessionId, task) => task(), - now: () => NOW - }) - - await coordinator.restore(sessionId) - - expect(persistTuiProviderHandle).toHaveBeenCalledWith({ - sessionId, - link: reproofed.link, - now: NOW - }) - expect(coordinator.status(sessionId)).toMatchObject({ owner: 'tui', phase: 'idle' }) - expect(store.getRecord(sessionId)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: null - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts deleted file mode 100644 index 91be1a15aa51..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionHandoffRequest, - AgentSessionHandoffStatus -} from '../../../shared/agent-session-wire' -import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' -import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' -import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' -import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' - -export function structuredManualRecoveryIsAdmissible( - record: AgentSessionRecord, - status: AgentSessionHandoffStatus | undefined -): boolean { - return ( - record.lease.handoffStage === 'manual-recovery' && - record.lease.runtimeKind === 'tui' && - record.lease.ownerProcess !== null && - status?.error?.canRetryProof === true - ) -} - -export function beginStructuredManualRecovery(input: { - deps: StructuredAgentSessionHandoffDeps - operationGuard: StructuredAgentSessionHandoffOperationGuard - callerKey: string - params: AgentSessionHandoffRequest - fingerprint: string - requireRecord: (sessionId: string) => AgentSessionRecord - restore: (sessionId: string) => Promise - setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void -}): Promise { - const { - callerKey, - deps, - fingerprint, - operationGuard, - params, - requireRecord, - restore, - setStatus - } = input - const sessionId = params.envelope.sessionId - operationGuard.start(sessionId, { - callerKey, - operationId: params.envelope.clientOperationId, - fingerprint - }) - setStatus(sessionId, { - owner: 'none', - direction: params.direction, - phase: 'switching', - stage: 'recovering', - operationId: params.envelope.clientOperationId, - hostLabel: deps.transport?.hostLabel - }) - return deps - .schedule(sessionId, async () => { - let record = requireRecord(sessionId) - if (record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) { - record = await setStoredAgentSessionHandoffStage(deps.store, { - sessionId, - fence: record.lease.runtimeFence, - stage: 'new-owner-proving', - handoffOperationId: record.lease.handoffOperationId, - now: deps.now() - }) - } - await restore(record.sessionId) - if (requireRecord(sessionId).lease.handoffStage === 'manual-recovery') { - throw new Error('The TUI owner proof is still unavailable.') - } - }) - .then(() => { - operationGuard.finish(sessionId, params.envelope.clientOperationId) - return deps.store.recordOperationOutcome({ - callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'succeeded', sessionId } - }) - }) - .catch(async (error) => { - await deps.store.recordOperationOutcome({ - callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'failed', code: 'agent_session_handoff_failed' } - }) - operationGuard.finish(sessionId, params.envelope.clientOperationId) - const status = idleStructuredHandoffStatus(requireRecord(sessionId)) - setStatus(sessionId, { - ...status, - ...(status.error - ? { - error: { - ...status.error, - details: error instanceof Error ? error.message : String(error) - } - } - : {}) - }) - }) - .finally(() => operationGuard.finish(sessionId, params.envelope.clientOperationId)) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-native-handoff-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-native-handoff-acquisition.ts deleted file mode 100644 index c88f0918d25e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-native-handoff-acquisition.ts +++ /dev/null @@ -1,133 +0,0 @@ -// The host's half of a TUI-to-native handoff acquiring its native child. -// -// Like attach, the new child writes through a sink this attempt owns: only a proven owner makes it -// the session's, so a failed acquire takes whatever its child queued with it instead of leaving it -// in the session's sink for the next drain to wait on. - -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { journalIdentityFor } from './structured-agent-session-attach' -import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' -import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' -import type { HostHandoffAccess } from './structured-agent-session-host-handoff' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' -import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' - -export async function acquireNativeHandoffOwner( - deps: StructuredAgentSessionHostDeps, - host: HostHandoffAccess, - input: { sessionId: string; fence: number; spawnToken: string } -): Promise { - const session = host.session(input.sessionId) - const record = deps.store.getRecord(input.sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - // Native handoff bypasses attach admission; reject before unbinding TUI ownership. - if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) { - throw new Error('structured_agent_session_unsupported') - } - const priorSink = host.eventSinks.eventSinkFor(input.sessionId) - const priorBarrier = await priorSink.drained() - if (!priorBarrier.ok) { - throw priorBarrier.error - } - priorSink.unbind() - // The new child writes through a sink this attempt owns; only a proven owner makes it the - // session's, so a failed acquire takes whatever its child queued with it. - const eventSink = host.eventSinks.mintEventSink(input.sessionId) - let adopted = false - try { - return await proveNativeHandoffOwner( - deps, - host, - { ...input, session, record }, - eventSink, - () => { - host.eventSinks.adoptEventSink(input.sessionId, eventSink) - adopted = true - } - ) - } finally { - if (!adopted) { - eventSink.close() - } - } -} - -async function proveNativeHandoffOwner( - deps: StructuredAgentSessionHostDeps, - host: HostHandoffAccess, - input: { - sessionId: string - fence: number - spawnToken: string - session: StructuredAgentSessionHostSession - record: AgentSessionRecord - }, - eventSink: DeferredStructuredAgentSessionEventSink, - adopt: () => void -): Promise { - const { session, record } = input - // Recheck immediately before acquisition; capability probes may drift while - // the old TUI event sink is draining. - if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) { - throw new Error('structured_agent_session_unsupported') - } - const acquired = await deps.adapter.acquire({ - identity: journalIdentityFor(record, session.params), - fence: input.fence, - spawnToken: input.spawnToken, - ...(record.options ? { options: record.options } : {}), - events: eventSink.sink - }) - let proved: AgentSessionRecord - try { - // A starting child has proven nothing yet: the record keeps the saved options as intent, and - // the `started` event persists what the child reports. - const options = - acquired.providerChildPhase === 'starting' - ? undefined - : await readNativeSessionOptions({ - adapter: deps.adapter, - sessionId: input.sessionId, - fence: input.fence, - ...(record.options ? { priorOptions: record.options } : {}) - }) - await deps.store.commitProcessIdentity({ - sessionId: input.sessionId, - fence: input.fence, - process: acquired.process, - now: host.now() - }) - proved = await deps.store.proveOwner({ - sessionId: input.sessionId, - fence: input.fence, - link: acquired.link, - now: host.now(), - ...(options ? { options } : {}) - }) - } catch (error) { - return rethrowAfterAgentSessionAcquisitionCleanup(deps.adapter, input.sessionId, error) - } - session.hasProviderChild = true - session.providerChildPhase = acquired.providerChildPhase ?? 'ready' - host.publishStatus?.(input.sessionId) - session.fence = proved.lease.runtimeFence - // A handoff moved the fence, not a restart: no writer is rebased across it. - delete session.resumedFromFence - session.acquisitionGeneration = acquired.acquisitionGeneration ?? null - adopt() - eventSink.bind({ - journal: session.journal, - fence: proved.lease.runtimeFence, - publish: (activity) => host.subscribers.publish(input.sessionId, session.journal, activity) - }) - const acquiredBarrier = await eventSink.drained() - if (!acquiredBarrier.ok) { - throw acquiredBarrier.error - } - host.subscribers.snapshot(input.sessionId, session.journal, proved.lease.runtimeFence) - return proved -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 5b4f05569159..7957ed326a8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' @@ -19,10 +19,6 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -36,14 +32,10 @@ let acquire: Mock let closeNativeSession: Mock> let activeModel: string let activeEffort: string | null -let transcriptPath: string let optionFailure: Error | null -let tuiLaunchFailure: Error | null /** What the adapter's closeSession reports about the child's exit. */ let closeSessionExit = true const dispatchedModels: string[] = [] -const launchedOptions: (Readonly> | undefined)[] = [] -const closedTuiOwners: StructuredTuiOwner[] = [] function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { @@ -58,55 +50,6 @@ function envelope(method: string, fields: Record): AgentSession } } -function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { - return { - terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, - process: { - hostId: 'local', - pid: 5200, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `tui-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - }, - transcriptPath - } -} - -function handoffTransport(): StructuredAgentSessionHandoffTransport { - return { - hostLabel: 'Test host', - launchTui: async ({ record, fence, spawnToken }) => { - if (tuiLaunchFailure) { - const error = tuiLaunchFailure - tuiLaunchFailure = null - throw error - } - launchedOptions.push(record.options) - return tuiOwner(fence, spawnToken) - }, - reproveTuiOwner: async ({ owner }) => owner, - recoverTuiOwner: async (record) => - tuiOwner( - record.lease.runtimeFence, - record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' - ), - stopRecoveredOwner: async () => undefined, - closeTuiOwner: async (owner) => { - closedTuiOwners.push(owner) - return { transcriptPath: owner.transcriptPath } - }, - waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle' - } -} - function adapter(): StructuredAgentSessionAdapter { acquire = vi.fn(async ({ fence, spawnToken, options }) => { activeModel = options?.model ?? DEFAULT_MODEL @@ -168,29 +111,14 @@ function adapter(): StructuredAgentSessionAdapter { } beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-')) + root = await mkdtemp(join(tmpdir(), 'orca-option-settlement-')) resetHostTestOperationIds() activeModel = DEFAULT_MODEL activeEffort = null optionFailure = null - tuiLaunchFailure = null closeSessionExit = true dispatchedModels.length = 0 - launchedOptions.length = 0 - closedTuiOwners.length = 0 const accountHome = join(root, 'codex-home') - const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12') - transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`) - await mkdir(sessionsDir, { recursive: true }) - await writeFile( - transcriptPath, - `${JSON.stringify({ - type: 'session_meta', - timestamp: '2026-08-12T10:00:00.000Z', - payload: { id: THREAD, session_id: THREAD } - })}\n`, - 'utf8' - ) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) router = adapter() host = new StructuredAgentSessionHost({ @@ -199,7 +127,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-native', - handoffTransport: handoffTransport(), now: () => NOW }) const attached = await host.attach( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts new file mode 100644 index 000000000000..3e11156b8836 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -0,0 +1,33 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' + +/** The `agentSession.handoffStatus` answer. Released desktop clients gate worktree activation on + * `owner`, so the method outlives the terminal handoff it was named for. */ +export function structuredAgentSessionOwnerStatus( + record: AgentSessionRecord +): AgentSessionHandoffStatus { + const { handoffStage: stage, handoffOperationId: operationId } = record.lease + if (stage === 'manual-recovery') { + return { + owner: 'none', + direction: 'to-native', + phase: 'failed', + stage, + operationId, + error: { + message: "Couldn't verify which runtime owns this session — manual recovery is required", + recoverableOwner: 'none' + } + } + } + return { + owner: + record.lease.claimStatus === 'live' && record.lease.ownerProcess + ? record.lease.runtimeKind + : 'none', + direction: stage ? 'to-native' : null, + phase: stage ? 'switching' : 'idle', + stage, + operationId + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts deleted file mode 100644 index aed78f3e5b95..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' -import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' -import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-proven-dead-retry' -const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' -const CREATE_OPERATION = `${NOW}-00000000000000000000000000000000` -const OPERATION = `${NOW}-00000000000000000000000000000001` -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('structured session proven-dead TUI retry', () => { - it('acquires native ownership without trying to close the dead TUI again', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-handoff-dead-retry-')) - roots.push(root) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'tui', - expectedFence: null, - spawnToken: 'tui-spawn', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: CREATE_OPERATION, fingerprint: 'create' }, - now: NOW - }) - const tuiFence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence: tuiFence, - process: { - hostId: 'local', - pid: 4200, - processStartTimeMs: NOW - 1_000, - spawnToken: 'tui-spawn' - }, - now: NOW - }) - await store.proveOwner({ - sessionId: SESSION, - fence: tuiFence, - link: { - linkId: 'tui-link', - handle: { provider: 'codex', threadId: THREAD }, - origin: 'created', - mintedAtFence: tuiFence, - observedAt: NOW - }, - now: NOW - }) - await recoverStoredDeadTuiOwnerForHandoff(store, { - sessionId: SESSION, - expectedFence: tuiFence, - operationId: OPERATION, - probe: { outcome: 'pid-absent' }, - now: NOW - }) - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: join(root, 'journal') - }) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'running-turn' }, - { - kind: 'status', - text: 'Working', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? tuiFence } - ) - const closeTuiOwner = - vi.fn>() - const coordinator = new StructuredAgentSessionHandoffCoordinator({ - store, - claimKeyId: 'key-1', - transport: { - hostLabel: 'Test host', - launchTui: vi.fn(), - reproveTuiOwner: vi.fn(), - recoverTuiOwner: vi.fn(), - stopRecoveredOwner: vi.fn(), - closeTuiOwner, - waitForTuiExit: vi.fn(), - waitForTuiIdleOrExit: vi.fn(), - tuiStatus: () => 'busy' - }, - session: () => ({ journal, fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 }), - suspendNative: vi.fn(), - acquireNative: async ({ fence, spawnToken }) => { - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { - hostId: 'local', - pid: 4300, - processStartTimeMs: NOW, - spawnToken - }, - now: NOW - }) - return store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'native-link', - handle: { provider: 'codex', threadId: THREAD }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW - }) - }, - acquireNativeStop: vi.fn(async () => true), - importTuiHistory: vi.fn(), - retryPendingSettlement: (sessionId) => - retryLoadedStructuredAgentSessionSettlement({ - deps: { store }, - sessionId, - session: { - journal, - fence: store.getRecord(sessionId)?.lease.runtimeFence ?? 1, - acquisitionGeneration: null - }, - now: () => NOW - }), - publish: vi.fn(), - schedule: async (_sessionId, task) => task(), - now: () => NOW - }) - const fields = { - direction: 'to-native' as const, - mode: 'now' as const, - action: 'retry' as const - } - const request: AgentSessionHandoffRequest = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.requestHandoff', - sessionId: SESSION, - fields - }) - }, - ...fields - } - - expect(coordinator.status(SESSION)).toMatchObject({ phase: 'failed', owner: 'tui' }) - expect( - await ( - coordinator as { - request: (callerKey: string, params: AgentSessionHandoffRequest) => Promise - } - ).request('client-1', request) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) - // Settle the flow's trailing outcome write before afterEach removes the store root. - await coordinator.drain() - expect(closeTuiOwner).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - claimStatus: 'live', - handoffStage: null - }) - expect(store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined() - expect(activeStructuredAgentSessionTurnId(journal.snapshot().items)).toBe(null) - }) -}) 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 index 543a1a7be709..65b913daf42a 100644 --- 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 @@ -1,7 +1,4 @@ -import type { - AgentSessionOwnerRuntimeKind, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' import { loadJournal } from '../agent-session-journal/journal-open' @@ -78,7 +75,6 @@ export function attachParamsForRecord( input: { clientOperationId: string expectedRuntimeFence: number - runtimeKind?: AgentSessionOwnerRuntimeKind } ): AgentSessionAttachParams { const params: AgentSessionAttachParams = { @@ -92,7 +88,7 @@ export function attachParamsForRecord( provider: record.provider, agent: record.provider, accountHome: record.accountHome, - runtimeKind: input.runtimeKind ?? record.lease.runtimeKind + runtimeKind: 'native' } return { ...params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 1bb03e95b2e1..5d579fbe256e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -27,8 +27,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined, - retrySettlement: async () => true, - restoreHandoff: async () => undefined + retrySettlement: async () => true }) await restorer.restore(['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 index a0099be32a71..a11c3a85b6c1 100644 --- 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 @@ -25,7 +25,6 @@ export class StructuredAgentSessionReadableRestorer { sessionId: string, params: RestoredStructuredAgentSessionRead['params'] ) => Promise - restoreHandoff: (sessionId: string) => Promise } ) {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 4f83a1855f3a..d6e4e341588f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -223,24 +223,36 @@ describe('structured session recovery resolution', () => { }) }) - it('leaves a TUI record that still names an owner to its own recovery transport', async () => { + it('waits out a terminal owner an older build recorded, and never stops it', async () => { const store = await openStore() await liveOwner(store, 'tui') await latch(store, 'recovering') + const stopOwnerProcess = vi.fn() expect( await resolveStructuredSessionRecovery( - deps(store, () => ({ outcome: 'pid-absent' })), + deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), { + stopOwnerProcess + }), SESSION ) - ).toBe('not-applicable') + ).toBe('unresolved') + expect(stopOwnerProcess).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'pid-absent' })), + SESSION + ) + ).toBe('resolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + claimStatus: 'released' + }) }) - it('resolves a TUI reservation that names nobody, because nothing else can', async () => { - // The TUI carve-out exists because a TUI owner has its own recovery transport, and that - // transport needs a process to talk to. A reservation that crashed before `commitProcessIdentity` - // names none, so skipping it here left the session with no exit at all. + it('resolves a TUI reservation that names nobody', async () => { const store = await openStore() await reserve(store, 'tui') await latch(store, 'recovering') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index fe545c29447d..adf142ddb730 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -39,7 +39,7 @@ export function structuredSessionRecoveryIsResolvable(record: AgentSessionRecord // Settlement latches are cleared only by a successful journal retry, never by owner probing. return false } - const { claimStatus, handoffStage, ownerProcess, runtimeKind } = record.lease + const { claimStatus, handoffStage, ownerProcess } = record.lease if (handoffStage !== 'recovering' && handoffStage !== 'manual-recovery') { return false } @@ -48,10 +48,7 @@ export function structuredSessionRecoveryIsResolvable(record: AgentSessionRecord // recorded there is nothing present-time evidence could settle, and the user decides. return ownerProcess !== null } - // A TUI owner has its own recovery transport — but that transport needs a process to talk to - // (`structuredManualRecoveryIsAdmissible` requires one). A TUI reservation that crashed before - // its identity was committed names nobody, so nothing else in the system can exit it. - return runtimeKind === 'native' || ownerProcess === null + return true } /** Stopping a matched owner is only Orca's call when Orca owned its transport. A conflicted claim diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 97f5321e8072..566156e096f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -26,7 +26,6 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' -import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -55,23 +54,7 @@ function operationId(timestamp = NOW): string { return `${timestamp}-${operationSequence.toString(16).padStart(32, '0')}` } -function handoffTransport(): StructuredAgentSessionHandoffTransport { - const unused = async (): Promise => { - throw new Error('unused handoff transport') - } - return { - hostLabel: 'test-host', - launchTui: unused, - reproveTuiOwner: unused, - recoverTuiOwner: unused, - stopRecoveredOwner: async () => undefined, - waitForTuiExit: unused, - waitForTuiIdleOrExit: unused, - tuiStatus: () => 'idle' - } -} - -async function createHarness(options: { attached?: boolean; transport?: boolean } = {}) { +async function createHarness(options: { attached?: boolean } = {}) { const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) const store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), @@ -110,8 +93,7 @@ async function createHarness(options: { attached?: boolean; transport?: boolean journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - now: () => NOW, - ...(options.transport ? { handoffTransport: handoffTransport() } : {}) + now: () => NOW }) const harness = { root, store, host, setOption } harnesses.push(harness) @@ -206,9 +188,7 @@ async function assertHostAgreement( } else { oracle = 'settled-rejected' } - expect(agentSessionRefusalOperationState(spec.method, code), `${spec.method}:${code}`).toBe( - oracle - ) + expect(agentSessionRefusalOperationState(code), `${spec.method}:${code}`).toBe(oracle) return `${spec.method}:${code}` } @@ -233,7 +213,7 @@ async function fillOperationLedger(harness: Harness): Promise { } } -// sendPlan and setOptionPlan have no unsupported branch; only handoff checked transport. +// sendPlan and setOptionPlan have no unsupported branch. const UNREACHABLE = new Set([ 'agentSession.send:structured_agent_session_unsupported', 'agentSession.setOption:structured_agent_session_unsupported', @@ -279,18 +259,18 @@ describe('agentSessionRefusalOperationState host oracle', () => { } expect(stale.setOption).toHaveBeenCalledTimes(1) - const conflict = await createHarness({ transport: true }) + const conflict = await createHarness() for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { await setLease(conflict, (current) => ({ ...current, - lease: { ...current.lease, runtimeKind: 'tui' } + lease: { ...current.lease, handoffStage: 'new-owner-proving' } })) const spec = { method, operationId: operationId() } record( await assertHostAgreement(conflict, spec, 'agent_session_conflict', async () => { await setLease(conflict, (current) => ({ ...current, - lease: { ...current.lease, runtimeKind: 'native' } + lease: { ...current.lease, handoffStage: null } })) return { harness: conflict, spec } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index d6a4a4397e42..f46ff48ce15b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -49,8 +49,7 @@ describe('restart journal restoration', () => { serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined, - retrySettlement: async () => true, - restoreHandoff: async () => undefined + retrySettlement: async () => true }) await vi.waitFor(() => expect(active).toBe(4)) @@ -62,7 +61,7 @@ describe('restart journal restoration', () => { expect(peak).toBe(4) }) - it('runs pending settlement retry after recovery resolution and before handoff', async () => { + it('runs pending settlement retry after recovery resolution', async () => { const calls: string[] = [] const params: AgentSessionAttachParams = { envelope: { @@ -108,25 +107,16 @@ describe('restart journal restoration', () => { restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement' ) return true - }, - restoreHandoff: async () => { - calls.push('restoreHandoff') } }, 'session-1' ) - expect(calls).toEqual([ - 'resolveRecovery', - 'onReadable', - 'retrySettlement:restored-params', - 'restoreHandoff' - ]) + expect(calls).toEqual(['resolveRecovery', 'onReadable', 'retrySettlement:restored-params']) }) it('does not rerun settlement retry when a second restore finds the session already open', async () => { const retrySettlement = vi.fn(async () => true) - const restoreHandoff = vi.fn(async () => undefined) restoreRead.mockResolvedValue({ journal: {}, params: {}, @@ -144,13 +134,11 @@ describe('restart journal restoration', () => { serialize: async (_sessionId, task) => task(), hasSession: () => true, onReadable: () => undefined, - retrySettlement, - restoreHandoff + retrySettlement }, 'session-1' ) expect(retrySettlement).not.toHaveBeenCalled() - expect(restoreHandoff).toHaveBeenCalledOnce() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 8cc202b28c76..38c04d2a5973 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -5,8 +5,8 @@ // stage the evidence now permits. And it owes a READABLE session: the journal open, history // answerable, the tab restorable. // -// It does not owe a provider child. This used to resume every record whose lease was `released` -// with no handoff in flight, which is the normal end state of a chat the user closed cleanly — so a +// It does not owe a provider child. This used to resume every record whose lease was `released`, +// which is the normal end state of a chat the user closed cleanly — so a // healthy profile started an app-server per session it had ever used, in parallel, at every launch, // with no client attached and nothing on screen. A child now exists because a surface asked for the // session (see `structured-agent-session-holds`), not because a record survived on disk. @@ -34,7 +34,6 @@ export type StructuredAgentSessionReadRestoreDeps = { sessionId: string, params: RestoredStructuredAgentSessionRead['params'] ) => Promise - restoreHandoff: (sessionId: string) => Promise } /** @@ -63,13 +62,12 @@ export async function restoreOneStructuredAgentSessionRead( export async function restoreOneStructuredAgentSessionReadUnderSerialize( input: Pick< StructuredAgentSessionReadRestoreDeps, - 'store' | 'journalRoot' | 'hasSession' | 'onReadable' | 'retrySettlement' | 'restoreHandoff' + 'store' | 'journalRoot' | 'hasSession' | 'onReadable' | 'retrySettlement' >, sessionId: string ): Promise { if (input.hasSession(sessionId)) { // A surface that took a hold mid-restore already attached this one. - await input.restoreHandoff(sessionId) return } const restored = await restoreStructuredAgentSessionRead( @@ -82,7 +80,6 @@ export async function restoreOneStructuredAgentSessionReadUnderSerialize( } input.onReadable(sessionId, restored) await input.retrySettlement(sessionId, restored.params) - await input.restoreHandoff(sessionId) } export async function restoreStructuredAgentSessionsOnRestart( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts index 0baf6fe99527..1b0540183710 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts @@ -30,8 +30,7 @@ export function structuredAgentSessionResumeParams( } return attachParamsForRecord(record, { clientOperationId, - expectedRuntimeFence: record.lease.runtimeFence, - runtimeKind: 'native' + expectedRuntimeFence: record.lease.runtimeFence }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index 8d99ea9098fb..291a6e0c53eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -40,7 +40,6 @@ function harness( options: { supports?: (record: AgentSessionRecord) => boolean } = {} ) { const live = new Map() - const restoreHandoff = vi.fn(async () => undefined) const serializedIds: string[] = [] const serialize = (sessionId: string, task: () => Promise): Promise => { serializedIds.push(sessionId) @@ -58,10 +57,9 @@ function harness( serialize, hasSession: (sessionId) => live.has(sessionId), onReadable: (sessionId, restored) => live.set(sessionId, restored), - retrySettlement: async () => true, - restoreHandoff + retrySettlement: async () => true }) - return { restorer, live, restoreHandoff, serializedIds } + return { restorer, live, serializedIds } } const readable = { journal: {}, params: {}, fence: 1 } as never 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 8676e86acfbb..bc36df76df8a 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 @@ -52,7 +52,7 @@ export async function revealStructuredAgentSession( /** * The host's whole readable-restore surface: the startup sweep and the on-demand reveal. * - * Bundled the way the handoff and lifetime collaborators are, because the two share the restorer + * Bundled the way the lifetime collaborators are, because the two share the restorer * and differ only in who is asking — startup, once, for everything; a surface, later, for one. */ export function createStructuredAgentSessionHostRestore( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index a467a318c808..f48af3b4d4b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -409,9 +409,9 @@ describe('a send with no live owner', () => { }) expect(dispatch).not.toHaveBeenCalled() expect(hostErrors).not.toEqual([]) - expect( - agentSessionRefusalOperationState('agentSession.send', 'agent_session_owner_restart_failed') - ).toBe('settled-rejected') + expect(agentSessionRefusalOperationState('agent_session_owner_restart_failed')).toBe( + 'settled-rejected' + ) // Refused before admission: the ledger holds nothing a resend would replay. expect(store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)).toBeNull() // The same status row a failed start leaves, so the reason outlives the error strip. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts index 120eeaeb6350..1000881220e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts @@ -110,14 +110,12 @@ export async function retryLoadedStructuredAgentSessionSettlement(input: { ) { throw new Error('agent_session_checkpoint_stale') } - // A dead-TUI retry still needs its stopped-owner stage; recovery-only stages end here. - const preserveHandoff = latest.lease.handoffStage === 'old-owner-stopped' return { ...latest, lease: { ...latest.lease, - handoffStage: preserveHandoff ? latest.lease.handoffStage : null, - handoffOperationId: preserveHandoff ? latest.lease.handoffOperationId : null, + handoffStage: null, + handoffOperationId: null, settlementRetryRequired: undefined, settlementRetryId: undefined, lastRenewedAt: input.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index e982dcb5d104..82f8c551642b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' import type { - AgentSessionHandoffStatus, AgentSessionStatusEvent, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' @@ -120,7 +119,7 @@ describe('AgentSessionSubscribers', () => { { fence: 1 } ) subscribers.publish(SESSION, journal) - subscribers.handoff(SESSION, 1, { owner: 'native' } as AgentSessionHandoffStatus) + subscribers.backgroundTasks(SESSION, null, 1) subscribers.reset(SESSION, journal, 'epoch_changed', 1) expect(events.map((event) => ('hostNow' in event ? event.hostNow : null))).toEqual([ @@ -205,13 +204,6 @@ describe('AgentSessionSubscribers', () => { subscribers.publish(SESSION, journal) subscribers.reset(SESSION, journal, 'epoch_changed', 1) subscribers.snapshot(SESSION, journal, 1) - subscribers.handoff(SESSION, 1, { - owner: 'native', - direction: null, - phase: 'idle', - stage: null, - operationId: null - }) expect(published).toEqual([SESSION, SESSION, SESSION]) }) @@ -284,51 +276,6 @@ describe('AgentSessionSubscribers', () => { }) }) - it('publishes handoff-only changes without serializing a transcript snapshot', async () => { - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - journalDir: join(root, 'journal') - }) - const subscribers = new AgentSessionSubscribers() - const events: AgentSessionSubscribeEvent[] = [] - subscribers.open({ - id: 'subscriber-1', - sessionId: SESSION, - journal, - fence: 1, - emit: (event) => events.push(event) - }) - const handoff: AgentSessionHandoffStatus = { - owner: 'native', - direction: 'to-tui', - phase: 'switching', - stage: 'preparing', - operationId: 'handoff-1' - } - - subscribers.handoff(SESSION, 2, handoff) - - expect(events.at(-1)).toEqual({ - type: 'batch', - sessionId: SESSION, - batch: { - cursor: journal.cursor(), - items: [], - removedItemIds: [], - submissions: [] - }, - fence: 2, - hostNow: expect.any(Number), - handoff - }) - }) - it('publishes background lifecycle without advancing the journal and carries its fence forward', async () => { const journal = await journals.open({ identity: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index ce5fb03c246a..af5e231730c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -12,7 +12,6 @@ import { AGENT_SESSION_HISTORY_MAX_LIMIT, type AgentSessionBackgroundTaskState, type AgentSessionSlashCommand, - type AgentSessionHandoffStatus, type AgentSessionSubscribeEvent, type AgentSessionTurnActivity } from '../../../shared/agent-session-wire' @@ -65,7 +64,6 @@ export class AgentSessionSubscribers { fence: number emit: AgentSessionSubscriberEmit cursor?: AgentJournalCursor - handoff?: AgentSessionHandoffStatus backgroundTasks?: AgentSessionBackgroundTaskState | null }): () => void { const liveCursor = input.journal.cursor() @@ -82,7 +80,7 @@ export class AgentSessionSubscribers { const hostNow = this.now() if (input.cursor) { - this.deliver(subscriber, input.journal, hostNow, input.handoff, true, input.backgroundTasks) + this.deliver(subscriber, input.journal, hostNow, true, input.backgroundTasks) } else { const page = readAgentSessionHydrationPage(input.journal, input.fence) this.emit(subscriber, { @@ -91,7 +89,6 @@ export class AgentSessionSubscribers { page, fence: input.fence, hostNow, - ...(input.handoff ? { handoff: input.handoff } : {}), ...(input.backgroundTasks !== undefined ? { backgroundTasks: input.backgroundTasks } : {}), ...this.activityField(input.sessionId) }) @@ -128,7 +125,7 @@ export class AgentSessionSubscribers { } const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { - this.deliver(subscriber, journal, hostNow, undefined, false, undefined, activity) + this.deliver(subscriber, journal, hostNow, false, undefined, activity) } if (activity === undefined) { this.hooks.onJournalPublished?.(sessionId, journal) @@ -181,21 +178,6 @@ export class AgentSessionSubscribers { this.hooks.onJournalPublished?.(sessionId, journal) } - handoff(sessionId: string, fence: number, handoff: AgentSessionHandoffStatus): void { - const hostNow = this.now() - for (const subscriber of this.subscribers(sessionId)) { - this.emit(subscriber, { - type: 'batch', - sessionId, - batch: emptyAgentSessionBatch(subscriber.cursor), - fence, - handoff, - hostNow - }) - subscriber.fence = fence - } - } - backgroundTasks( sessionId: string, state: AgentSessionBackgroundTaskState | null, @@ -223,7 +205,6 @@ export class AgentSessionSubscribers { subscriber: Subscriber, journal: AgentSessionJournal, hostNow: number, - handoff?: AgentSessionHandoffStatus, emitCheckpoint = false, backgroundTasks?: AgentSessionBackgroundTaskState | null, activity?: AgentSessionTurnActivity | null @@ -249,7 +230,6 @@ export class AgentSessionSubscribers { page, fence: subscriber.fence, hostNow, - ...(handoff ? { handoff } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) }) @@ -262,14 +242,13 @@ export class AgentSessionSubscribers { const commandsChanged = this.hooks.readCommands !== undefined && (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands - if (handoff || emitCheckpoint || publishedActivity !== undefined || commandsChanged) { + if (emitCheckpoint || publishedActivity !== undefined || commandsChanged) { this.emit(subscriber, { type: 'batch', sessionId: subscriber.sessionId, batch: emptyAgentSessionBatch(page.window.nextCursor), fence: subscriber.fence, hostNow, - ...(handoff ? { handoff } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) }) @@ -287,7 +266,6 @@ export class AgentSessionSubscribers { }, fence: subscriber.fence, hostNow, - ...(handoff ? { handoff } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 2071b74ed459..d1b17ef49b52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -3,11 +3,12 @@ // Two leaks meet here and each has to be tested against the real host, not a double: a chat that // closes without stopping its app-server, and a launch that starts one for every record on disk. -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' @@ -20,11 +21,6 @@ import { AgentSessionRecordStore } from '../../runtime/agent-session-record-stor import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' -import { StructuredHandoffTestRequests } from './structured-agent-session-handoff-test-requests' import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { @@ -64,8 +60,7 @@ function adapter(): StructuredAgentSessionAdapter { } function openHost( - probeOwner?: (record: never) => Promise, - handoffTransport?: StructuredAgentSessionHandoffTransport + probeOwner?: (record: AgentSessionRecord) => Promise ): void { host = new StructuredAgentSessionHost({ store, @@ -77,8 +72,7 @@ function openHost( now: () => NOW, onEventSinkError: ({ error }) => hostErrors.push(error), statusSink, - ...(probeOwner ? { probeOwner: probeOwner as never } : {}), - ...(handoffTransport ? { handoffTransport } : {}) + ...(probeOwner ? { probeOwner } : {}) }) } @@ -128,75 +122,6 @@ function waitOutSeveralGraceWindows(): Promise { return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) } -const handoffRequests = new StructuredHandoffTestRequests( - NOW, - SESSION, - () => store.getRecord(SESSION)?.lease.runtimeFence ?? 0 -) -/** Whether the terminal this host handed the session to can be reached again. */ -let tuiRecoverable: boolean - -/** One operation-id source with the rest of the suite, so the durable ledger sees no duplicate. */ -function handoffRequest(direction: 'to-tui' | 'to-native') { - return handoffRequests.request(direction, 'now', { operationId: hostTestOperationId() }) -} - -function tuiOwner(fence: number, spawnToken: string, transcriptPath: string): StructuredTuiOwner { - return { - terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, - process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `tui-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - }, - transcriptPath - } -} - -/** A codex rollout the return trip can import, so a real to-native handoff has history to read. */ -async function writeTuiTranscript(): Promise { - const sessionsDir = join(root, 'codex-home', 'sessions', '2026', '08', '12') - await mkdir(sessionsDir, { recursive: true }) - const transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`) - await writeFile( - transcriptPath, - `${JSON.stringify({ - type: 'session_meta', - timestamp: '2026-08-12T10:00:00.000Z', - payload: { id: THREAD, session_id: THREAD } - })}\n`, - 'utf8' - ) - return transcriptPath -} - -/** Replaces the current host with one that can hand the session to a terminal and take it back. */ -function openHandoffHost(transcriptPath: string): void { - openHost(undefined, { - hostLabel: 'Test host', - launchTui: async ({ fence, spawnToken }) => tuiOwner(fence, spawnToken, transcriptPath), - reproveTuiOwner: async ({ owner }) => owner, - recoverTuiOwner: async (record) => { - if (!tuiRecoverable) { - throw new Error('the owning terminal could not be reached') - } - return tuiOwner( - record.lease.runtimeFence, - record.lease.reservedSpawnToken ?? 'recovered', - transcriptPath - ) - }, - stopRecoveredOwner: async () => undefined, - closeTuiOwner: async (owner) => ({ transcriptPath: owner.transcriptPath }), - waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle' - }) -} - /** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. */ function failNextDrain(): void { vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained').mockResolvedValueOnce({ @@ -227,8 +152,6 @@ async function sendPending(text: string): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-surface-lifetime-')) - handoffRequests.reset() - tuiRecoverable = true resetHostTestOperationIds() sink = null hostErrors = [] @@ -824,63 +747,6 @@ describe('an unexpected provider exit', () => { }) }) -describe('a chat handed to a terminal and taken back', () => { - // The wind-down a close owes belongs to the child in front of it, not to whatever the LAST - // eviction found. A session a terminal owns is indexed with no child of its own, so a close - // there records "nothing owed" — and the trip back re-acquires into that SAME session object. - it('settles and releases the child it was given back', async () => { - const transcriptPath = await writeTuiTranscript() - await host.flushAllStreamedEvents() - openHandoffHost(transcriptPath) - await attach() - expect(await host.requestHandoff(CALLER, handoffRequest('to-tui'))).toMatchObject({ ok: true }) - // Real-timer poll: the suite's default 1000ms budget is tight under a loaded CI shard. - await vi.waitFor( - async () => expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }), - { timeout: 5000 } - ) - - // The app restarts and cannot reach the terminal, so this generation restores the session for - // reading and holds no handle to the owner it would otherwise stop on a close. - await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - tuiRecoverable = false - openHandoffHost(transcriptPath) - await host.restoreReadableSessions() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live' - }) - - failNextDrain() - await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) - expect(host.hasSession(SESSION)).toBe(true) - - // The terminal answers again, and the status read the reopened pane makes recovers the owner. - tuiRecoverable = true - expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }) - expect(await host.requestHandoff(CALLER, handoffRequest('to-native'))).toMatchObject({ - ok: true - }) - // Real-timer poll: the suite's default 1000ms budget is tight under a loaded CI shard. - await vi.waitFor( - async () => expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }), - { timeout: 5000 } - ) - expect(host['sessions'].get(SESSION)?.hasProviderChild).toBe(true) - await sendPending('pending when the retaken chat closes') - const settled = captureSettledSubmissions() - - await expect(host.close(SESSION)).resolves.toBeUndefined() - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) - describe('a quit over an eviction that never got its retry', () => { // Nothing calls `close` a second time when the user quits instead of reopening the chat, so the // quit sweep is the last thing that can hand the lease back — and it only reaches the session if diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts deleted file mode 100644 index 19f0c2941803..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Host teardown against a handoff that has not finished switching owners. -// -// The flow runs on the session's serialized chain and nothing else awaits it, so a teardown that -// only flushed sinks left it writing into a journal it had just closed — and publishing a status -// against a session it had just dropped, which surfaced as an unhandled rejection. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { structuredAgentSessionHostTeardownPhases } from './structured-agent-session-host-teardown' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { StructuredHandoffTestRequests } from './structured-agent-session-handoff-test-requests' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let launchEntered: PromiseWithResolvers -let launchGate: PromiseWithResolvers - -const requests = new StructuredHandoffTestRequests( - NOW, - SESSION, - () => store.getRecord(SESSION)?.lease.runtimeFence ?? 0 -) - -function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { - return { - terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, - process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `tui-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } -} - -function gatedTransport(): StructuredAgentSessionHandoffTransport { - return { - hostLabel: 'Test host', - launchTui: async ({ fence, spawnToken }) => { - launchEntered.resolve() - await launchGate.promise - return tuiOwner(fence, spawnToken) - }, - reproveTuiOwner: async ({ owner }) => owner, - recoverTuiOwner: async (record) => - tuiOwner(record.lease.runtimeFence, record.lease.reservedSpawnToken ?? 'recovered'), - stopRecoveredOwner: async () => undefined, - stopFailedTuiLaunch: async () => undefined, - closeTuiOwner: async (owner) => ({ transcriptPath: owner.transcriptPath }), - waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle' - } -} - -function adapter(): StructuredAgentSessionAdapter { - return { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `native-link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, - origin: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - } - })), - dispatchTurn: vi.fn(async () => ({ state: 'accepted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - closeSession: vi.fn(async () => true), - supportsCreate: () => true, - supportsRecord: () => true - } as unknown as StructuredAgentSessionAdapter -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-teardown-handoff-drain-')) - resetHostTestOperationIds() - launchEntered = Promise.withResolvers() - launchGate = Promise.withResolvers() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - host = new StructuredAgentSessionHost({ - store, - adapter: adapter(), - journalRoot: root, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-native', - handoffTransport: gatedTransport(), - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - launchGate.resolve() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('structured agent-session host teardown', () => { - it('waits for an in-flight handoff before dropping the session it is switching', async () => { - // One operation-id source with attach, so the durable ledger sees no duplicate. - const request = requests.request('to-tui', 'now', { operationId: hostTestOperationId() }) - expect(await host.requestHandoff(CALLER, request)).toMatchObject({ ok: true }) - await launchEntered.promise - - let settled = false - const teardown = host.flushAllStreamedEvents().then(() => { - settled = true - }) - // Quiescence probe, not a wait for the flow: teardown must still be blocked on it. - for (let tick = 0; tick < 20; tick += 1) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - expect(settled).toBe(false) - - launchGate.resolve() - await teardown - - // Teardown stops the late TUI owner and fences the reservation before dropping the session. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - claimStatus: 'released', - handoffStage: 'old-owner-stopped' - }) - expect(host.hasSession(SESSION)).toBe(false) - }) - - it('names every phase, so the quit-path order is pinned rather than incidental', () => { - const noop = async (): Promise => undefined - const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, - handoffs: { stopTuiHistoryCatchup: () => undefined, drain: noop }, - tasks: { drainAttaches: noop }, - evictOwnedSessions: noop, - beginResumeMarkers: () => {}, - recordResumeMarkers: noop - }) - expect(phases.map((phase) => phase.name)).toEqual([ - 'begin-resume-markers', - 'dispose-holds', - 'stop-lease-renewal', - 'stop-tui-catchup', - 'drain-handoffs', - 'drain-attaches', - 'evict-owned-sessions', - 'record-resume-markers', - 'flush-event-sinks' - ]) - }) - - it('bounds stalled recovery publication without preventing later cleanup', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const pending = Promise.withResolvers() - const cleaned = vi.fn(async () => {}) - const flush = vi.fn(async () => cleaned()) - const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: cleaned }, - runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, - handoffs: { stopTuiHistoryCatchup: () => {}, drain: cleaned }, - tasks: { drainAttaches: cleaned }, - evictOwnedSessions: cleaned, - beginResumeMarkers: () => {}, - recordResumeMarkers: () => pending.promise - }) - try { - const teardown = (async () => { - for (const phase of phases) { - await phase.run() - } - })() - await vi.advanceTimersByTimeAsync(2000) - await teardown - expect(cleaned).toHaveBeenCalledTimes(5) - expect(warning).toHaveBeenCalledWith( - '[structured-agent-session] recording recovery capsule failed' - ) - expect(vi.getTimerCount()).toBe(0) - } finally { - pending.resolve() - warning.mockRestore() - vi.useRealTimers() - } - }) - - it('gives up on a wedged handoff instead of holding the quit open', async () => { - const request = requests.request('to-tui', 'now', { operationId: hostTestOperationId() }) - expect(await host.requestHandoff(CALLER, request)).toMatchObject({ ok: true }) - await launchEntered.promise - - // The gate is never opened: this is the flow that never comes back. - vi.useFakeTimers() - try { - const teardown = host.flushAllStreamedEvents() - // Covers both bounded phases: the resume-marker write gives up first, then the handoff drain. - await vi.advanceTimersByTimeAsync(10_000) - await expect(teardown).resolves.toBeUndefined() - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index fcd5625a48a1..585cbd4fbbdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -49,7 +49,7 @@ afterEach(async () => { }) describe('structured agent-session outbound admission', () => { - it('admits bounded initial, handoff, epoch, compaction, and history recovery frames', async () => { + it('admits bounded initial, background-task, epoch, compaction, and history recovery frames', async () => { expect(Buffer.byteLength(JSON.stringify(journal.snapshot()), 'utf8')).toBeGreaterThan( REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES ) @@ -67,13 +67,7 @@ describe('structured agent-session outbound admission', () => { expect(initial[0]).toMatchObject({ type: 'snapshot', page: { hasOlder: true } }) expectAdmitted(initial[0]) - subscribers.handoff(SESSION, 2, { - owner: 'native', - direction: 'to-tui', - phase: 'switching', - stage: 'preparing', - operationId: 'handoff-1' - }) + subscribers.backgroundTasks(SESSION, null, 2) subscribers.snapshot(SESSION, journal, 2) expect(initial.slice(1)).toHaveLength(2) initial.slice(1).forEach(expectAdmitted) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts deleted file mode 100644 index 704f05fad609..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write' - -const BOUNDARY_FILE = 'structured-tui-transcript-boundary.json' -const BOUNDARY_SCHEMA_VERSION = 1 - -export type StructuredTuiTranscriptBoundary = { - providerSessionId: string - runtimeFence: number - filePath: string | null - offset: number -} - -function boundaryPath(journalDirectory: string): string { - return join(journalDirectory, BOUNDARY_FILE) -} - -export async function readStructuredTuiTranscriptBoundary( - journalDirectory: string -): Promise { - let parsed: unknown - try { - parsed = JSON.parse(await readFile(boundaryPath(journalDirectory), 'utf8')) - } catch { - return null - } - if (!parsed || typeof parsed !== 'object') { - return null - } - const value = parsed as Record - if ( - value.schemaVersion !== BOUNDARY_SCHEMA_VERSION || - typeof value.providerSessionId !== 'string' || - !Number.isSafeInteger(value.runtimeFence) || - (value.filePath !== null && typeof value.filePath !== 'string') || - !Number.isSafeInteger(value.offset) || - (value.offset as number) < 0 - ) { - return null - } - return { - providerSessionId: value.providerSessionId, - runtimeFence: value.runtimeFence as number, - filePath: value.filePath as string | null, - offset: value.offset as number - } -} - -export async function writeStructuredTuiTranscriptBoundary( - journalDirectory: string, - boundary: StructuredTuiTranscriptBoundary -): Promise { - const filePath = boundaryPath(journalDirectory) - await writeFileDurable( - durableWriteTempPath(filePath), - filePath, - JSON.stringify({ schemaVersion: BOUNDARY_SCHEMA_VERSION, ...boundary }) - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts deleted file mode 100644 index 9866b811c613..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' - -const journals = createTrackedJournalOpener() - -const NOW = 1_800_000_000_000 -const SESSION = 'session-catchup' -const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -let root: string -let store: AgentSessionRecordStore - -function rolloutLine(message: string): string { - return `${JSON.stringify({ - type: 'event_msg', - timestamp: '2026-08-11T10:00:00.000Z', - payload: { type: 'agent_message', message } - })}\n` -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-tui-catchup-')) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -async function createCatchupFixture() { - const accountHome = join(root, 'isolated-codex-home') - const sessionsDir = join(accountHome, 'sessions', '2026', '08', '11') - const rollout = join(sessionsDir, `rollout-2026-08-11T10-00-00-${THREAD}.jsonl`) - await mkdir(sessionsDir, { recursive: true }) - await writeFile(rollout, rolloutLine('before handoff'), 'utf8') - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: accountHome }, - runtimeKind: 'tui', - expectedFence: null, - spawnToken: 'tui-token', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'test', - operationId: `${NOW}-${'1'.padStart(32, '0')}`, - fingerprint: 'initial' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { - hostId: 'local', - pid: 4200, - processStartTimeMs: NOW - 1_000, - spawnToken: 'tui-token' - }, - now: NOW - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'tui-link', - handle: { provider: 'codex', threadId: THREAD }, - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW - }) - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: join(root, 'journal') - }) - return { fence, journal, rollout } -} - -function createCatchup(input: Awaited>) { - return new StructuredTuiTranscriptCatchup({ - store, - session: () => ({ - hasProviderChild: false, - providerChildPhase: 'ready', - journal: input.journal, - params: {} as never, - fence: input.fence, - acquisitionGeneration: null - }), - schedule: async (_sessionId, task) => task(), - publish: vi.fn(), - reset: vi.fn() - }) -} - -describe('StructuredTuiTranscriptCatchup', () => { - it('tails only TUI-era appends from the durable account home', async () => { - const fixture = await createCatchupFixture() - const catchup = createCatchup(fixture) - - await catchup.prepare(SESSION, fixture.fence) - await catchup.activate(SESSION) - expect(fixture.journal.snapshot().items).toEqual([]) - - await appendFile(fixture.rollout, rolloutLine('during TUI'), 'utf8') - await vi.waitFor(() => - expect(fixture.journal.snapshot().items.map((item) => item.body)).toContainEqual({ - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'during TUI' }] - }) - ) - - catchup.stop(SESSION) - await appendFile(fixture.rollout, rolloutLine('after stop'), 'utf8') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(fixture.journal.snapshot().items).toHaveLength(1) - }) - - it('replays transcript writes made while the host watcher was down', async () => { - const fixture = await createCatchupFixture() - const beforeCrash = createCatchup(fixture) - await beforeCrash.prepare(SESSION, fixture.fence) - await beforeCrash.activate(SESSION) - await appendFile(fixture.rollout, rolloutLine('before host crash'), 'utf8') - await vi.waitFor(() => expect(fixture.journal.snapshot().items).toHaveLength(1)) - beforeCrash.stopAll() - - await appendFile(fixture.rollout, rolloutLine('while host was down'), 'utf8') - const recovered = createCatchup(fixture) - await recovered.recover(SESSION, fixture.fence) - await recovered.activate(SESSION) - - const text = fixture.journal - .snapshot() - .items.flatMap((item) => - item.body.kind === 'message' - ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) - : [] - ) - expect(text).toEqual(['before host crash', 'while host was down']) - recovered.stopAll() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts deleted file mode 100644 index 10ce2416a328..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { stat } from 'node:fs/promises' -import { join } from 'node:path' -import type { NativeChatMessage } from '../../../shared/native-chat-types' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { - appendLegacyTranscriptMessages, - importLegacyTranscriptIntoJournal -} from '../agent-session-journal/journal-legacy-import' -import { resolveSessionFilePath } from '../session-file-resolver' -import { - readIncrementalTranscriptMessages, - type IncrementalTranscriptState -} from '../transcript-incremental-reader' -import { nativeChatLineDecoderForAgent } from '../transcript-tail-reader' -import { - subscribeNativeChatTranscript, - type NativeChatTranscriptSubscription -} from '../transcript-watch' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' -import { - readStructuredTuiTranscriptBoundary, - writeStructuredTuiTranscriptBoundary -} from './structured-tui-transcript-boundary' - -type CatchupState = { - controller: AbortController - initialReady: (() => void) | null - active: boolean - fence: number - agent: AgentSessionHandleProvider - providerSessionId: string - pending: NativeChatMessage[] - seen: Set - subscription: NativeChatTranscriptSubscription | null -} - -export class StructuredTuiTranscriptCatchup { - private readonly states = new Map() - private readonly teardown = new AbortController() - - constructor( - private readonly input: { - store: AgentSessionRecordStore - session: (sessionId: string) => StructuredAgentSessionHostSession - schedule: (sessionId: string, task: () => Promise) => Promise - publish: (sessionId: string) => void - reset: (sessionId: string, fence: number) => void - onError?: (input: { sessionId: string; error: unknown }) => void - } - ) {} - - async prepare(sessionId: string, fence: number): Promise { - return this.start(sessionId, fence, false) - } - - async recover(sessionId: string, fence: number): Promise { - return this.start(sessionId, fence, true) - } - - private async start(sessionId: string, fence: number, recovering: boolean): Promise { - this.teardown.signal.throwIfAborted() - this.stop(sessionId) - const record = this.input.store.getRecord(sessionId) - const head = record?.providerHandleChain.at(-1) - if ( - !record || - !head || - (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') - ) { - return this.teardown.signal - } - const agent = head.handle.provider - const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId - const journal = this.input.session(sessionId).journal - const transcriptOptions = - agent === 'claude' - ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } - : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } - const state: CatchupState = { - controller: new AbortController(), - initialReady: null, - active: false, - fence, - agent, - providerSessionId, - pending: [], - seen: new Set(), - subscription: null - } - const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages) - this.states.set(sessionId, state) - try { - const signal = state.controller.signal - const boundary = recovering - ? await readStructuredTuiTranscriptBoundary(journal.directory) - : null - signal.throwIfAborted() - const filePath = await resolveSessionFilePath( - agent, - providerSessionId, - { - ...transcriptOptions, - ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) - }, - signal - ) - signal.throwIfAborted() - let baselineOffset = 0 - const ready = filePath ? new Promise((resolve) => (state.initialReady = resolve)) : null - state.subscription = await subscribeNativeChatTranscript( - { - agent, - sessionId: providerSessionId, - ...transcriptOptions, - ...(filePath ? { filePath, initialLimit: 0 } : {}), - onInitialSnapshot: (messages, _hasMore, beforeOffset) => { - baselineOffset = beforeOffset - receive(messages) - state.initialReady?.() - state.initialReady = null - }, - onAppend: receive - }, - signal - ) - signal.throwIfAborted() - await ready - signal.throwIfAborted() - if (!recovering) { - await writeStructuredTuiTranscriptBoundary(journal.directory, { - providerSessionId, - runtimeFence: fence, - filePath, - offset: baselineOffset - }) - } else if ( - filePath && - boundary?.providerSessionId === providerSessionId && - boundary.runtimeFence === fence && - boundary.filePath === filePath - ) { - await this.readRecoveryGap(sessionId, state, filePath, boundary.offset) - } else if (filePath) { - const imported = await importLegacyTranscriptIntoJournal({ - journal, - agent, - sessionId: providerSessionId, - fence, - options: { filePath, decodedMessageIdentities: true } - }) - if (!imported.ok) { - throw new Error(imported.error) - } - signal.throwIfAborted() - this.input.reset(sessionId, fence) - } - signal.throwIfAborted() - return signal - } catch (error) { - const stopped = state.controller.signal.aborted - if (this.states.get(sessionId) === state) { - this.stop(sessionId) - } else { - state.subscription?.unsubscribe() - } - if (stopped) { - state.controller.signal.throwIfAborted() - } - throw error - } - } - - private async readRecoveryGap( - sessionId: string, - state: CatchupState, - filePath: string, - offset: number - ): Promise { - let size: number - try { - size = (await stat(filePath)).size - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return - } - throw error - } - const start = offset <= size ? offset : 0 - const incremental: IncrementalTranscriptState = { - offset: start, - pendingChunks: [], - pendingStart: start, - pendingBytes: 0, - droppingOversizedRecord: false - } - const decode = nativeChatLineDecoderForAgent(state.agent) - if (!decode) { - throw new Error('Transcript unavailable') - } - let messages: NativeChatMessage[] - try { - messages = await readIncrementalTranscriptMessages(filePath, incremental, decode) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return - } - throw error - } - this.receive(sessionId, state, messages) - } - - async activate(sessionId: string): Promise { - const state = this.states.get(sessionId) - if (!state) { - return - } - state.active = true - const pending = state.pending.splice(0) - await this.append(sessionId, state, pending) - } - - stop(sessionId: string): void { - const state = this.states.get(sessionId) - this.states.delete(sessionId) - state?.controller.abort(new StructuredTuiCatchupStoppedError()) - state?.initialReady?.() - if (state) { - state.initialReady = null - } - state?.subscription?.unsubscribe() - } - - stopAll(): void { - this.teardown.abort(new StructuredTuiCatchupStoppedError()) - for (const sessionId of this.states.keys()) { - this.stop(sessionId) - } - } - - private receive(sessionId: string, state: CatchupState, messages: NativeChatMessage[]): void { - if (this.states.get(sessionId) !== state) { - return - } - if (!state.active) { - state.pending.push(...messages) - return - } - void this.input - .schedule(sessionId, () => this.append(sessionId, state, messages)) - .catch((error) => this.input.onError?.({ sessionId, error })) - } - - private async append( - sessionId: string, - state: CatchupState, - messages: NativeChatMessage[] - ): Promise { - if (this.states.get(sessionId) !== state) { - return - } - const record = this.input.store.getRecord(sessionId) - if ( - !record || - record.lease.runtimeKind !== 'tui' || - record.lease.claimStatus !== 'live' || - record.lease.runtimeFence !== state.fence - ) { - return - } - const ids = new Set(state.seen) - const fresh = messages.filter((message) => { - if (ids.has(message.id)) { - return false - } - ids.add(message.id) - return true - }) - if (fresh.length === 0) { - return - } - try { - await appendLegacyTranscriptMessages({ - journal: this.input.session(sessionId).journal, - agent: state.agent, - sessionId: state.providerSessionId, - fence: state.fence, - messages: fresh - }) - for (const message of fresh) { - state.seen.add(message.id) - } - this.input.publish(sessionId) - } catch (error) { - this.input.onError?.({ sessionId, error }) - } - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-close.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-close.test.ts deleted file mode 100644 index fa29002c934a..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-close.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { appendFile, mkdir, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { beforeEach, expect, it, vi } from 'vitest' -import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' -import { - CALLER, - adapter, - hostTestState, - replaceHostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId -} from './structured-agent-session-host-test-data' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { StructuredHandoffTestRequests } from './structured-agent-session-handoff-test-requests' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -let host: StructuredAgentSessionHost -let rollout: string -let watcherBaseline: number -let closeTuiOwner: ReturnType< - typeof vi.fn> -> - -function rolloutLine(message: string): string { - return `${JSON.stringify({ - type: 'event_msg', - timestamp: '2026-08-11T10:00:00.000Z', - payload: { type: 'agent_message', message } - })}\n` -} - -function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { - return { - terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, - process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `tui-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } -} - -async function expectTranscriptMessage(text: string): Promise { - await appendFile(rollout, rolloutLine(text)) - await vi.waitFor(() => { - const history = host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - history.ok && - history.page.items.some( - (item) => - item.body.kind === 'message' && - item.body.blocks.some((block) => block.type === 'text' && block.text === text) - ) - ).toBe(true) - }) -} - -beforeEach(async () => { - const initial = hostTestState() - await initial.host.flushAllStreamedEvents() - watcherBaseline = getActiveNativeChatWatcherCount() - closeTuiOwner = vi.fn(async () => ({})) - host = new StructuredAgentSessionHost({ - ...initial.host.deps, - adapter: { ...adapter(), closeSession: vi.fn(async () => true) }, - handoffTransport: { - hostLabel: 'Test host', - launchTui: async ({ fence, spawnToken }) => tuiOwner(fence, spawnToken), - reproveTuiOwner: async ({ owner }) => owner, - recoverTuiOwner: async (record) => - tuiOwner(record.lease.runtimeFence, record.lease.reservedSpawnToken ?? 'recovered'), - stopRecoveredOwner: async () => undefined, - closeTuiOwner, - waitForTuiExit: async () => ({}), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle' - } - }) - replaceHostTestState({ host, store: initial.store }) - const accountHome = join(initial.root, 'codex-home') - const sessionsDir = join(accountHome, 'sessions', '2026', '08', '11') - await mkdir(sessionsDir, { recursive: true }) - rollout = join(sessionsDir, `rollout-2026-08-11T10-00-00-${THREAD}.jsonl`) - await writeFile(rollout, rolloutLine('before handoff')) - expect( - await host.attach( - CALLER, - hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) - ) - ).toMatchObject({ ok: true }) - const requests = new StructuredHandoffTestRequests( - NOW, - SESSION, - () => initial.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - ) - expect( - await host.requestHandoff( - CALLER, - requests.request('to-tui', 'now', { operationId: hostTestOperationId() }) - ) - ).toMatchObject({ ok: true }) - await host['handoffs'].drain() - expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline + 1) -}) - -it('retires the transcript watcher when a live TUI session closes', async () => { - await expectTranscriptMessage('while TUI live') - await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(hostTestState().store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline) - await appendFile(rollout, rolloutLine('after TUI close')) - await host.close(SESSION) - expect(closeTuiOwner).toHaveBeenCalledOnce() - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline) -}) - -it('keeps live history when terminal stop is unverified and retires it on retry', async () => { - closeTuiOwner.mockRejectedValueOnce(new Error('terminal exit unverified')) - await expect(host.close(SESSION)).rejects.toThrow('terminal exit unverified') - expect(host.hasSession(SESSION)).toBe(true) - expect(hostTestState().store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline + 1) - await expectTranscriptMessage('after unverified stop') - await host.close(SESSION) - expect(closeTuiOwner).toHaveBeenCalledTimes(2) - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline) -}) - -it('keeps the watcher until the durable owner transition succeeds', async () => { - vi.spyOn(hostTestState().store, 'transitionHandoff').mockRejectedValueOnce( - new Error('lease write failed') - ) - await expect(host.close(SESSION)).rejects.toThrow('lease write failed') - expect(host.hasSession(SESSION)).toBe(true) - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline + 1) - await host.close(SESSION) - expect(closeTuiOwner).toHaveBeenCalledTimes(2) - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline) -}) - -it('keeps transcript cleanup complete when later journal eviction needs retry', async () => { - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - if (!session) { - throw new Error('TUI session missing') - } - vi.spyOn(session.journal, 'close').mockRejectedValueOnce(new Error('journal close failed')) - await expect(host.close(SESSION)).rejects.toThrow('forget-session') - expect(host.hasSession(SESSION)).toBe(true) - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline) - await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(closeTuiOwner).toHaveBeenCalledOnce() - expect(getActiveNativeChatWatcherCount()).toBe(watcherBaseline) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts deleted file mode 100644 index 6a9080d4a9eb..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type * as Resolver from '../session-file-resolver' -import type * as TranscriptWatch from '../transcript-watch' -import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' -import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' -import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' -import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' -import { createTuiTranscriptTeardownFixture } from './structured-tui-transcript-teardown-test-fixture' - -const gate = vi.hoisted(() => ({ - mode: '', - entered: Promise.withResolvers(), - release: Promise.withResolvers(), - cleanups: new Set<() => void>() -})) - -vi.mock('../session-file-resolver', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - resolveSessionFilePath: async (...args: Parameters) => { - if (gate.mode === 'resolve') { - gate.mode = '' - gate.entered.resolve() - await gate.release.promise - } - return actual.resolveSessionFilePath(...args) - } - } -}) - -vi.mock('../transcript-watch', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - subscribeNativeChatTranscript: async ( - ...args: Parameters - ) => { - const subscription = await actual.subscribeNativeChatTranscript(...args) - gate.cleanups.add(subscription.unsubscribe) - if (gate.mode === 'subscribe') { - gate.mode = '' - gate.entered.resolve() - await gate.release.promise - } - return subscription - } - } -}) - -let fixture: Awaited> -let catchup: StructuredTuiTranscriptCatchup - -beforeEach(async () => { - gate.mode = '' - gate.entered = Promise.withResolvers() - gate.release = Promise.withResolvers() - fixture = await createTuiTranscriptTeardownFixture() - catchup = new StructuredTuiTranscriptCatchup({ - store: fixture.store, - session: (sessionId) => { - const session = fixture.host['sessions'].get(sessionId) - if (!session) { - throw new Error('Session fixture missing') - } - return session - }, - schedule: (_sessionId, task) => task(), - publish: vi.fn(), - reset: vi.fn() - }) -}) - -afterEach(() => { - gate.release.resolve() - catchup.stopAll() - for (const cleanup of gate.cleanups) { - cleanup() - } - gate.cleanups.clear() - vi.restoreAllMocks() -}) - -it.each([ - { method: 'prepare', mode: 'resolve' }, - { method: 'prepare', mode: 'subscribe' }, - { method: 'recover', mode: 'resolve' }, - { method: 'recover', mode: 'subscribe' } -] as const)( - 'preserves replacement ownership after canceled $method at $mode completes', - async ({ method, mode }) => { - gate.mode = mode - const old = catchup[method](SESSION, 1) - const rejected = expect(old).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) - await gate.entered.promise - const replacement = await catchup[method === 'prepare' ? 'recover' : 'prepare'](SESSION, 2) - gate.release.resolve() - await rejected - expect(replacement.aborted).toBe(false) - expect(catchup['states'].get(SESSION)?.fence).toBe(2) - expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline + 1) - catchup.stop(SESSION) - expect(replacement.aborted).toBe(true) - expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) - } -) - -it('allows a new per-session catchup after stop but rejects every start after stopAll', async () => { - const first = await catchup.prepare(SESSION, 1) - catchup.stop(SESSION) - expect(first.aborted).toBe(true) - const replacement = await catchup.prepare(SESSION, 2) - expect(replacement.aborted).toBe(false) - catchup.stopAll() - catchup.stopAll() - await expect(catchup.prepare(SESSION, 3)).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) - await expect(catchup.recover(SESSION, 3)).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) - expect(catchup['states'].size).toBe(0) - expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) -}) - -it('fences an unsupported preparation result when teardown runs before its consumer', async () => { - vi.spyOn(fixture.store, 'getRecord').mockReturnValueOnce(null) - const prepared = await catchup.prepare(SESSION, 1) - expect(prepared.aborted).toBe(false) - expect(catchup['states'].size).toBe(0) - catchup.stopAll() - expect(() => prepared.throwIfAborted()).toThrow(StructuredTuiCatchupStoppedError) - expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts deleted file mode 100644 index 71f9576f368c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { expect, vi } from 'vitest' -import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' -import { - CALLER, - adapter, - hostTestState, - replaceHostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId -} from './structured-agent-session-host-test-data' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { StructuredHandoffTestRequests } from './structured-agent-session-handoff-test-requests' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from './structured-agent-session-handoff-types' - -function rolloutLine(message: string): string { - return `${JSON.stringify({ - type: 'event_msg', - timestamp: '2026-08-11T10:00:00.000Z', - payload: { type: 'agent_message', message } - })}\n` -} - -function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { - return { - terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, - process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `tui-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } -} - -export async function createTuiTranscriptTeardownFixture() { - const initial = hostTestState() - await initial.host.flushAllStreamedEvents() - const watcherBaseline = getActiveNativeChatWatcherCount() - const closeTuiOwner = vi.fn(async () => ({})) - const launchTui = vi.fn( - async ({ fence, spawnToken }) => tuiOwner(fence, spawnToken) - ) - const host = new StructuredAgentSessionHost({ - ...initial.host.deps, - adapter: { ...adapter(), closeSession: vi.fn(async () => true) }, - handoffTransport: { - hostLabel: 'Test host', - launchTui, - reproveTuiOwner: async ({ owner }) => owner, - recoverTuiOwner: async (record) => - tuiOwner(record.lease.runtimeFence, record.lease.reservedSpawnToken ?? 'recovered'), - stopRecoveredOwner: async () => undefined, - closeTuiOwner, - waitForTuiExit: async () => ({}), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle' - } - }) - replaceHostTestState({ host, store: initial.store }) - const accountHome = join(initial.root, 'codex-home') - const sessionsDir = join(accountHome, 'sessions', '2026', '08', '11') - await mkdir(sessionsDir, { recursive: true }) - const rollout = join(sessionsDir, `rollout-2026-08-11T10-00-00-${THREAD}.jsonl`) - await writeFile(rollout, rolloutLine('before handoff')) - expect( - await host.attach( - CALLER, - hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) - ) - ).toMatchObject({ ok: true }) - const requests = new StructuredHandoffTestRequests( - NOW, - SESSION, - () => initial.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - ) - return { - host, - store: initial.store, - acquire: initial.acquire, - launchTui, - rollout, - watcherBaseline, - async requestHandoff() { - expect( - await host.requestHandoff( - CALLER, - requests.request('to-tui', 'now', { operationId: hostTestOperationId() }) - ) - ).toMatchObject({ ok: true }) - } - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts deleted file mode 100644 index fdf18e4f9df2..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type * as Resolver from '../session-file-resolver' -import type * as TranscriptWatch from '../transcript-watch' -import type * as TranscriptTail from '../transcript-tail-reader' -import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' -import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' -import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' -import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' -import { createTuiTranscriptTeardownFixture } from './structured-tui-transcript-teardown-test-fixture' - -const gate = vi.hoisted(() => ({ - mode: '', - entered: Promise.withResolvers(), - release: Promise.withResolvers(), - cleanups: new Set<() => void>() -})) - -vi.mock('../session-file-resolver', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - resolveSessionFilePath: async (...args: Parameters) => { - if (gate.mode === 'resolve-error') { - gate.mode = '' - throw new Error('transcript read failed') - } - if (gate.mode === 'resolve') { - gate.mode = '' - gate.entered.resolve() - await gate.release.promise - } - return actual.resolveSessionFilePath(...args) - } - } -}) - -vi.mock('../transcript-watch', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - subscribeNativeChatTranscript: async ( - ...args: Parameters - ) => { - const subscription = await actual.subscribeNativeChatTranscript(...args) - gate.cleanups.add(subscription.unsubscribe) - if (gate.mode === 'subscribe') { - gate.mode = '' - gate.entered.resolve() - await gate.release.promise - } - return subscription - } - } -}) - -vi.mock('../transcript-tail-reader', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readNativeChatTranscriptTailFile: async ( - ...args: Parameters - ) => { - if (gate.mode === 'initial-ready') { - gate.mode = '' - gate.entered.resolve() - await gate.release.promise - } - return actual.readNativeChatTranscriptTailFile(...args) - } - } -}) - -let fixture: Awaited> - -beforeEach(async () => { - gate.mode = '' - gate.entered = Promise.withResolvers() - gate.release = Promise.withResolvers() - fixture = await createTuiTranscriptTeardownFixture() -}) - -afterEach(async () => { - gate.release.resolve() - for (const cleanup of gate.cleanups) { - cleanup() - } - gate.cleanups.clear() - vi.restoreAllMocks() -}) - -async function beginTeardown() { - const stopped = Promise.withResolvers() - const handoffs = fixture.host['handoffs'] - const stop = handoffs.stopTuiHistoryCatchup.bind(handoffs) - vi.spyOn(handoffs, 'stopTuiHistoryCatchup').mockImplementation(() => { - stop() - stopped.resolve() - }) - const completed = fixture.host.flushAllStreamedEvents() - await stopped.promise - return { completed } -} - -it.each(['resolve', 'subscribe', 'initial-ready', 'before-prepare', 'after-prepare'])( - 'cancels transcript acquisition during host teardown at %s', - async (mode) => { - gate.mode = mode - if (mode === 'before-prepare') { - vi.spyOn(fixture.host.deps.adapter, 'closeSession').mockImplementationOnce(async () => { - gate.entered.resolve() - await gate.release.promise - return true - }) - } else if (mode === 'after-prepare') { - const prepare = StructuredTuiTranscriptCatchup.prototype.prepare - vi.spyOn(StructuredTuiTranscriptCatchup.prototype, 'prepare').mockImplementation( - async function (this: StructuredTuiTranscriptCatchup, sessionId, fence) { - const signal = await prepare.call(this, sessionId, fence) - gate.entered.resolve() - await gate.release.promise - return signal - } - ) - } - await fixture.requestHandoff() - await gate.entered.promise - const teardown = await beginTeardown() - gate.release.resolve() - await teardown.completed - expect(fixture.host.hasSession(SESSION)).toBe(false) - expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) - expect(fixture.launchTui).not.toHaveBeenCalled() - expect(fixture.acquire).toHaveBeenCalledOnce() - expect(fixture.host['handoffs']['flowRunner']['active'].size).toBe(0) - expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - claimStatus: 'released', - handoffStage: 'old-owner-stopped', - ownerProcess: null, - reservedSpawnToken: null - }) - } -) - -it('keeps native recovery for an ordinary preparation failure', async () => { - gate.mode = 'resolve-error' - const acquire = fixture.acquire.getMockImplementation() - if (!acquire) { - throw new Error('Native acquisition fixture missing') - } - fixture.acquire.mockImplementationOnce(async (...args) => { - gate.entered.resolve() - await gate.release.promise - return acquire(...args) - }) - await fixture.requestHandoff() - await gate.entered.promise - expect(fixture.acquire).toHaveBeenCalledTimes(2) - gate.release.resolve() - await fixture.host['handoffs'].drain() - expect(fixture.launchTui).not.toHaveBeenCalled() - expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - claimStatus: 'live', - handoffStage: null - }) - expect((await fixture.host.handoffStatus(SESSION)).error?.details).toBe('transcript read failed') -}) - -it('cancels recovered TUI catchup without relabeling the live owner or retrying', async () => { - await fixture.requestHandoff() - await fixture.host['handoffs'].drain() - const recover = vi.spyOn(StructuredTuiTranscriptCatchup.prototype, 'recover') - gate.mode = 'resolve' - const restoring = fixture.host['handoffs'].restore(SESSION) - const rejected = expect(restoring).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) - await gate.entered.promise - const teardown = await beginTeardown() - gate.release.resolve() - await rejected - await teardown.completed - expect(recover).toHaveBeenCalledOnce() - expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) - expect(fixture.acquire).toHaveBeenCalledOnce() - expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: null - }) -}) diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index ffc97c9efa1c..1ef945b1ea21 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -3,8 +3,11 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' -import { readClaudeTranscriptLeafUuid, resolveSessionFilePath } from './session-file-resolver' +import { + ClaudeTranscriptTailIncompleteError, + proveClaudeTranscriptBranch +} from '../claude/claude-transcript-branch-proof' +import { resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -13,6 +16,16 @@ afterEach(async () => { tempRoots = [] }) +async function readClaudeTranscriptLeafUuid( + transcriptPath: string, + providerSessionId: string, + previousLeafUuid: string | null = null +): Promise { + return ( + await proveClaudeTranscriptBranch({ transcriptPath, providerSessionId, previousLeafUuid }) + ).leafUuid +} + async function makeRoot(prefix: string): Promise { const root = await mkdtemp(join(tmpdir(), prefix)) tempRoots.push(root) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 2d668a6ab2be..0f7d7c7367f7 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -21,7 +21,6 @@ import { } from './host-readable-transcript-path' import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' -import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -155,21 +154,6 @@ export async function resolveSessionFilePath( return resolved } -/** Read and validate Claude's authoritative transcript branch marker. */ -export async function readClaudeTranscriptLeafUuid( - transcriptPath: string, - providerSessionId: string, - previousLeafUuid: string | null = null -): Promise { - return ( - await proveClaudeTranscriptBranch({ - transcriptPath, - providerSessionId, - previousLeafUuid - }) - ).leafUuid -} - async function resolveSessionFileById( transcriptAgent: NativeChatTranscriptAgent, sessionId: string, diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.test.ts b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts deleted file mode 100644 index 5bd232c3041f..000000000000 --- a/src/main/runtime/agent-session-handoff-lease-transitions.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { recoverDeadTuiOwnerForHandoff } from './agent-session-handoff-lease-transitions' -import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' - -describe('agent session handoff restart transitions', () => { - it('turns a proven dead TUI owner into one durable retry owner', () => { - const operationId = '1800000000000-00000000000000000000000000000001' - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'tui', runtimeFence: 3 }) - ) - - const next = recoverDeadTuiOwnerForHandoff({ - record, - expectedFence: 3, - operationId, - probe: { outcome: 'pid-absent' }, - now: 1_800_000_001_000 - }) - - expect(next.lease).toMatchObject({ - runtimeKind: 'tui', - runtimeFence: 4, - handoffStage: 'old-owner-stopped', - handoffOperationId: operationId, - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'pid-absent' } - }) - }) - - it('preserves the stopped owner and operation for durable retry', () => { - const handoffOperationId = '1800000000000-00000000000000000000000000000001' - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ - runtimeKind: 'native', - runtimeFence: 4, - handoffStage: 'old-owner-stopped', - handoffOperationId, - claimStatus: 'released', - ownerProcess: null, - reservedSpawnToken: null, - unreconciled: true - }) - ) - - const next = applyAgentSessionRestartAdjudication({ - record, - probe: { outcome: 'reservation-unused' }, - now: 1_800_000_001_000 - }) - - expect(next.lease).toMatchObject({ - runtimeKind: 'native', - runtimeFence: 4, - handoffStage: 'old-owner-stopped', - handoffOperationId, - claimStatus: 'released', - ownerProcess: null, - unreconciled: false - }) - }) -}) diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts deleted file mode 100644 index 2987d06e21cd..000000000000 --- a/src/main/runtime/agent-session-handoff-lease-transitions.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { - AgentSessionOwnerRuntimeKind, - AgentSessionRecord -} from '../../shared/agent-session-record' -import { - assertFence, - evictAgentSessionOwner, - reserveAgentSessionOwner, - withLease -} from './agent-session-lease-transitions' - -export function recoverDeadTuiOwnerForHandoff(args: { - record: AgentSessionRecord - expectedFence: number - operationId: string - probe: AgentSessionOwnerProbe - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.expectedFence) - if ( - record.lease.runtimeKind !== 'tui' || - record.lease.handoffStage !== null || - record.lease.claimStatus !== 'live' || - record.lease.ownerProcess === null - ) { - throw new Error('agent_session_ownership_unknown') - } - const evicted = evictAgentSessionOwner({ ...args, journalSettlement: 'required' }) - return withLease(evicted, { - ...evicted.lease, - handoffStage: 'old-owner-stopped', - handoffOperationId: args.operationId - }) -} - -export function stopAgentSessionOwnerForHandoff(args: { - record: AgentSessionRecord - expectedFence: number - operationId: string - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.expectedFence) - if ( - record.lease.handoffStage !== 'preparing' || - record.lease.handoffOperationId !== args.operationId || - record.lease.ownerProcess === null - ) { - throw new Error('agent_session_ownership_unknown') - } - return withLease(record, { - ...record.lease, - runtimeFence: nextAgentSessionFence(record.lease), - handoffStage: 'old-owner-stopped', - ownerProcess: null, - reservedSpawnToken: null, - processlessAt: null, - claimStatus: 'released', - lastRenewedAt: args.now, - deathEvidence: { - kind: 'exit-observed', - detail: 'observed process exit', - observedAt: args.now - } - }) -} - -export function rollbackAgentSessionHandoffPreparation(args: { - record: AgentSessionRecord - expectedFence: number - operationId: string - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.expectedFence) - if ( - record.lease.handoffStage !== 'preparing' || - record.lease.handoffOperationId !== args.operationId || - record.lease.claimStatus !== 'live' || - record.lease.ownerProcess === null - ) { - throw new Error('agent_session_ownership_unknown') - } - return withLease(record, { - ...record.lease, - handoffStage: null, - handoffOperationId: null, - lastRenewedAt: args.now - }) -} - -export function stopRecoveringTuiOwnerForHandoff(args: { - record: AgentSessionRecord - expectedFence: number - operationId: string - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.expectedFence) - if ( - (record.lease.handoffStage !== 'recovering' && - record.lease.handoffStage !== 'manual-recovery') || - record.lease.runtimeKind !== 'tui' || - record.lease.handoffOperationId !== null || - record.lease.claimStatus !== 'live' || - record.lease.ownerProcess === null - ) { - throw new Error('agent_session_ownership_unknown') - } - return withLease(record, { - ...record.lease, - runtimeFence: nextAgentSessionFence(record.lease), - handoffStage: 'old-owner-stopped', - handoffOperationId: args.operationId, - ownerProcess: null, - reservedSpawnToken: null, - processlessAt: null, - claimStatus: 'released', - lastRenewedAt: args.now, - deathEvidence: { - kind: 'exit-observed', - detail: 'recovery proved TUI process exit', - observedAt: args.now - } - }) -} - -export function reserveAgentSessionHandoffOwner(args: { - record: AgentSessionRecord - expectedFence: number - runtimeKind: AgentSessionOwnerRuntimeKind - spawnToken: string - operationId: string - claimKeyId: string - now: number - leaseTtlMs: number -}): AgentSessionRecord { - return reserveAgentSessionOwner({ - record: args.record, - expectedFence: args.expectedFence, - probe: { outcome: 'reservation-unused' }, - reservation: { - runtimeKind: args.runtimeKind, - spawnToken: args.spawnToken, - claimKeyId: args.claimKeyId, - handoffOperationId: args.operationId, - leaseTtlMs: args.leaseTtlMs, - now: args.now - } - }).record -} - -export function abandonAgentSessionHandoffAttempt(args: { - record: AgentSessionRecord - expectedFence: number - operationId: string - recoverableRuntimeKind: AgentSessionOwnerRuntimeKind - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.expectedFence) - if ( - record.lease.handoffStage !== 'new-owner-proving' || - record.lease.handoffOperationId !== args.operationId - ) { - throw new Error('agent_session_ownership_unknown') - } - return withLease(record, { - ...record.lease, - runtimeKind: args.recoverableRuntimeKind, - runtimeFence: nextAgentSessionFence(record.lease), - handoffStage: 'old-owner-stopped', - ownerProcess: null, - reservedSpawnToken: null, - processlessAt: null, - claimStatus: 'released', - lastRenewedAt: args.now, - deathEvidence: { - kind: 'exit-observed', - detail: 'handoff launch attempt stopped', - observedAt: args.now - } - }) -} diff --git a/src/main/runtime/agent-session-handoff-record-transitions.ts b/src/main/runtime/agent-session-handoff-record-transitions.ts deleted file mode 100644 index dd8e25531b7f..000000000000 --- a/src/main/runtime/agent-session-handoff-record-transitions.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionHandoffStage } from '../../shared/agent-session-record' -import { - abandonAgentSessionHandoffAttempt, - recoverDeadTuiOwnerForHandoff, - reserveAgentSessionHandoffOwner, - rollbackAgentSessionHandoffPreparation, - stopAgentSessionOwnerForHandoff, - stopRecoveringTuiOwnerForHandoff -} from './agent-session-handoff-lease-transitions' -import { setAgentSessionHandoffStage } from './agent-session-lease-transitions' -import { - AGENT_SESSION_LEASE_TTL_MS, - type AgentSessionRecordStore -} from './agent-session-record-store' - -export function setStoredAgentSessionHandoffStage( - store: AgentSessionRecordStore, - args: { - sessionId: string - fence: number - stage: AgentSessionHandoffStage | null - handoffOperationId: string | null - now: number - } -) { - return store.transitionHandoff(args.sessionId, (record) => - setAgentSessionHandoffStage({ ...args, record }) - ) -} - -export function recoverStoredDeadTuiOwnerForHandoff( - store: AgentSessionRecordStore, - args: { - sessionId: string - expectedFence: number - operationId: string - probe: AgentSessionOwnerProbe - now: number - } -) { - return store.transitionHandoff(args.sessionId, (record) => - recoverDeadTuiOwnerForHandoff({ ...args, record }) - ) -} - -export function stopStoredAgentSessionOwnerForHandoff( - store: AgentSessionRecordStore, - args: { sessionId: string; expectedFence: number; operationId: string; now: number } -) { - return store.transitionHandoff(args.sessionId, (record) => - stopAgentSessionOwnerForHandoff({ ...args, record }) - ) -} - -export function rollbackStoredAgentSessionHandoffPreparation( - store: AgentSessionRecordStore, - args: { sessionId: string; expectedFence: number; operationId: string; now: number } -) { - return store.transitionHandoff(args.sessionId, (record) => - rollbackAgentSessionHandoffPreparation({ ...args, record }) - ) -} - -export function stopStoredRecoveringTuiOwnerForHandoff( - store: AgentSessionRecordStore, - args: { sessionId: string; expectedFence: number; operationId: string; now: number } -) { - return store.transitionHandoff(args.sessionId, (record) => - stopRecoveringTuiOwnerForHandoff({ ...args, record }) - ) -} - -export function reserveStoredAgentSessionHandoffOwner( - store: AgentSessionRecordStore, - args: { - sessionId: string - expectedFence: number - runtimeKind: 'native' | 'tui' - spawnToken: string - operationId: string - claimKeyId: string - now: number - leaseTtlMs?: number - } -) { - return store.transitionHandoff(args.sessionId, (record) => - reserveAgentSessionHandoffOwner({ - ...args, - record, - leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS - }) - ) -} - -export function abandonStoredAgentSessionHandoffAttempt( - store: AgentSessionRecordStore, - args: { - sessionId: string - expectedFence: number - operationId: string - recoverableRuntimeKind: 'native' | 'tui' - now: number - } -) { - return store.transitionHandoff(args.sessionId, (record) => - abandonAgentSessionHandoffAttempt({ ...args, record }) - ) -} diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 28617817647a..92c0ae274656 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -18,7 +18,6 @@ import { } from '../../shared/agent-session-provider-handle' import type { AgentSessionJournalCheckpoint, - AgentSessionHandoffStage, AgentSessionLease, AgentSessionOwnerRuntimeKind, AgentSessionProcessIdentity, @@ -253,30 +252,6 @@ export function evictAgentSessionOwner(args: { }) } -export function setAgentSessionHandoffStage(args: { - record: AgentSessionRecord - fence: number - stage: AgentSessionHandoffStage | null - handoffOperationId: string | null - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.fence) - if ( - record.lease.handoffOperationId !== null && - args.handoffOperationId !== null && - args.handoffOperationId !== record.lease.handoffOperationId - ) { - throw new Error('agent_session_operation_conflict') - } - return withLease(record, { - ...record.lease, - handoffStage: args.stage, - handoffOperationId: args.handoffOperationId, - lastRenewedAt: args.now - }) -} - export function setAgentSessionJournalCheckpoint(args: { record: AgentSessionRecord fence: number diff --git a/src/main/runtime/agent-session-pty-write-gate.test.ts b/src/main/runtime/agent-session-pty-write-gate.test.ts index 94ad79c051bb..7c339131a6be 100644 --- a/src/main/runtime/agent-session-pty-write-gate.test.ts +++ b/src/main/runtime/agent-session-pty-write-gate.test.ts @@ -149,78 +149,6 @@ describe('admission through the store', () => { } ) - it('admits only the reserved TUI proof token while the new process is proving', () => { - publish( - agentSessionLeaseFixture({ - runtimeKind: 'tui', - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - ownerProcess: { - hostId: 'local', - pid: 4200, - processStartTimeMs: 10, - spawnToken: 'proof-token' - }, - reservedSpawnToken: 'proof-token' - }) - ) - expect(gate.admit(PTY_ID).admitted).toBe(false) - expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(true) - expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( - false - ) - expect( - gate.admitProof(PTY_ID, { sessionId: 'session-beta-2', spawnToken: 'proof-token' }) - ).toBe(false) - }) - - it('refuses proof input that does not match the committed process identity', () => { - publish( - agentSessionLeaseFixture({ - runtimeKind: 'tui', - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - ownerProcess: { - hostId: 'local', - pid: 4200, - processStartTimeMs: 10, - spawnToken: 'other-token' - }, - reservedSpawnToken: 'proof-token' - }) - ) - expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe( - false - ) - }) - - it('refuses proof input after ownership is live', () => { - publish(agentSessionLeaseFixture({ reservedSpawnToken: 'proof-token' })) - expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe( - false - ) - }) - - it('admits proof input for the exact proven live owner during restore', () => { - publish( - agentSessionLeaseFixture({ - ownerProcess: { - hostId: 'local', - pid: 4200, - processStartTimeMs: 10, - spawnToken: 'live-proof-token' - }, - reservedSpawnToken: 'live-proof-token' - }) - ) - expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'live-proof-token' })).toBe( - true - ) - expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( - false - ) - }) - it('refuses when the record vanished from the store', () => { const admission = gate.admit(PTY_ID) expect(admission.admitted).toBe(false) diff --git a/src/main/runtime/agent-session-pty-write-gate.ts b/src/main/runtime/agent-session-pty-write-gate.ts index 8fb078eb3b97..662016ad6ef4 100644 --- a/src/main/runtime/agent-session-pty-write-gate.ts +++ b/src/main/runtime/agent-session-pty-write-gate.ts @@ -116,29 +116,6 @@ export class AgentSessionPtyWriteGate { return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId)) } - /** Narrow pre-ownership input for the reserved TUI's provider identity probe. */ - admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean { - const binding = this.binding(ptyId) - const lease = binding?.record?.lease - const provingReservation = - lease?.claimStatus === 'reserved' && - lease.handoffStage === 'new-owner-proving' && - lease.reservedSpawnToken === authority.spawnToken && - (lease.ownerProcess === null || lease.ownerProcess.spawnToken === authority.spawnToken) - const reprovingLiveOwner = - lease?.claimStatus === 'live' && - lease.handoffStage === null && - lease.ownerProcess?.spawnToken === authority.spawnToken && - lease.provenHandleLinkId !== null - return Boolean( - binding?.sessionId === authority.sessionId && - binding.record?.sessionId === authority.sessionId && - lease?.runtimeKind === 'tui' && - (provingReservation || reprovingLiveOwner) && - !lease.unreconciled - ) - } - /** Re-check a write already in flight against the fence it was admitted under. */ readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission { if (admitted.sessionId === null && !this.enforcing) { diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 208eb7431b4c..d3525de2de90 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -9,7 +9,6 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' -import { setStoredAgentSessionHandoffStage } from './agent-session-handoff-record-transitions' import { AgentSessionRecordStore } from './agent-session-record-store' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' import { @@ -779,27 +778,6 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => { expect(advanced.lease.journalCheckpoint).toEqual({ epoch: 3, sequence: 0 }) }) - it('rejects a handoff stage change under a different operation id', async () => { - const store = await open() - await establishOwner(store) - await setStoredAgentSessionHandoffStage(store, { - sessionId: 'session-alpha', - fence: 1, - stage: 'preparing', - handoffOperationId: 'op-1', - now: NOW - }) - await expect( - setStoredAgentSessionHandoffStage(store, { - sessionId: 'session-alpha', - fence: 1, - stage: 'old-owner-stopped', - handoffOperationId: 'op-2', - now: NOW - }) - ).rejects.toThrow('agent_session_operation_conflict') - }) - it.each([ [ 'invalid checkpoint', diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts deleted file mode 100644 index 0a4e3c39a540..000000000000 --- a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AGENT_SESSION_RECORD_SCHEMA_VERSION, - type AgentSessionRecord -} from '../../shared/agent-session-record' -import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' - -const NOW = 1_800_000_000_000 - -function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord { - return { - schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, - sessionId: 'session-restart', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - providerHandleChain: [], - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, - lease: { - sessionId: 'session-restart', - runtimeKind: stage === 'preparing' ? 'native' : 'tui', - runtimeFence: 4, - handoffStage: stage, - provenHandleLinkId: null, - ownerProcess: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: 'spawn-restart' - }, - reservedSpawnToken: 'spawn-restart', - leaseDeadlineAt: NOW + 30_000, - lastRenewedAt: NOW, - handoffOperationId: 'handoff-op-1', - journalCheckpoint: null, - claimKeyId: 'key-1', - claimStatus: stage === 'preparing' ? 'live' : 'reserved', - unreconciled: true, - deathEvidence: null - }, - createdAt: NOW, - updatedAt: NOW - } -} - -describe('restarted handoff adjudication', () => { - it('continues a dead preparing owner at old-owner-stopped under the same operation', () => { - expect( - adjudicateRestartedAgentSessionHandoff( - record('preparing'), - { outcome: 'pid-absent' }, - NOW + 1_000 - ).lease - ).toMatchObject({ - runtimeKind: 'native', - runtimeFence: 5, - handoffStage: 'old-owner-stopped', - handoffOperationId: 'handoff-op-1', - claimStatus: 'released', - ownerProcess: null - }) - }) - - it('routes an ownerless indeterminate reservation to manual recovery at the same fence', () => { - const abandoned = record('new-owner-proving') - abandoned.lease.runtimeKind = 'native' - abandoned.lease.ownerProcess = null - expect( - adjudicateRestartedAgentSessionHandoff( - abandoned, - { outcome: 'indeterminate', reason: 'no spawn-token scan' }, - NOW + 1_000 - ).lease - ).toMatchObject({ - runtimeKind: 'native', - runtimeFence: 4, - handoffStage: 'manual-recovery', - handoffOperationId: 'handoff-op-1', - claimStatus: 'reserved', - ownerProcess: null, - reservedSpawnToken: 'spawn-restart' - }) - }) - - it('releases a proving reservation only with durable processless proof', () => { - const processless = record('new-owner-proving') - processless.lease.runtimeKind = 'native' - processless.lease.ownerProcess = null - processless.lease.processlessAt = NOW - expect( - adjudicateRestartedAgentSessionHandoff( - processless, - { outcome: 'reservation-unused' }, - NOW + 1_000 - ).lease - ).toMatchObject({ - runtimeKind: 'native', - runtimeFence: 5, - handoffStage: null, - handoffOperationId: null, - claimStatus: 'released', - reservedSpawnToken: null - }) - }) - - it.each([ - ['dead pid', { outcome: 'pid-absent' } as const], - [ - 'reused pid with a different start time', - { outcome: 'identity-mismatch', field: 'process-start-time' } as const - ] - ])('releases a proving owner with exact %s proof', (_name, probe) => { - const proving = record('new-owner-proving') - proving.lease.runtimeKind = 'native' - expect(adjudicateRestartedAgentSessionHandoff(proving, probe, NOW + 1_000).lease).toMatchObject( - { - runtimeKind: 'native', - runtimeFence: 5, - handoffStage: null, - handoffOperationId: null, - claimStatus: 'released', - ownerProcess: null, - reservedSpawnToken: null - } - ) - }) - - it('preserves the existing TUI handoff rollback after proving its target dead', () => { - expect( - adjudicateRestartedAgentSessionHandoff( - record('new-owner-proving'), - { outcome: 'pid-absent' }, - NOW + 1_000 - ).lease - ).toMatchObject({ - runtimeKind: 'native', - runtimeFence: 5, - handoffStage: 'old-owner-stopped', - handoffOperationId: 'handoff-op-1' - }) - }) - - it.each([ - [ - 'live exact identity', - { outcome: 'identity-matched', matchedOn: ['process-start-time'] } as const, - 'recovering' - ], - [ - 'indeterminate identity', - { outcome: 'indeterminate', reason: 'start time unavailable' } as const, - 'recovering' - ] - ] as const)('keeps the fence and token for a %s', (_name, probe, expectedStage) => { - const proving = record('new-owner-proving') - proving.lease.runtimeKind = 'native' - expect(adjudicateRestartedAgentSessionHandoff(proving, probe, NOW + 1_000).lease).toMatchObject( - { - runtimeFence: 4, - handoffStage: expectedStage, - handoffOperationId: 'handoff-op-1', - claimStatus: 'reserved', - ownerProcess: { pid: 4242, processStartTimeMs: NOW - 1_000 }, - reservedSpawnToken: 'spawn-restart' - } - ) - }) -}) diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.ts deleted file mode 100644 index 3f78fa2e456a..000000000000 --- a/src/main/runtime/agent-session-restart-handoff-adjudication.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - adjudicateAgentSessionRestart, - type AgentSessionOwnerProbe -} from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../shared/agent-session-record' - -export function adjudicateRestartedAgentSessionHandoff( - record: AgentSessionRecord, - probe: AgentSessionOwnerProbe, - now: number -): AgentSessionRecord { - const adjudication = adjudicateAgentSessionRestart({ - lease: record.lease, - probe, - observedAt: now - }) - if (adjudication.disposition === 'readopt') { - return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) - } - if (adjudication.disposition === 'settlement-pending') { - return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) - } - if (adjudication.disposition === 'free') { - return updateLease(record, { - ...record.lease, - handoffStage: null, - handoffOperationId: null, - processlessAt: null, - unreconciled: false, - lastRenewedAt: now - }) - } - if (adjudication.disposition !== 'evicted') { - return updateLease(record, { - ...record.lease, - handoffStage: - adjudication.disposition === 'conflicted' ? 'manual-recovery' : adjudication.stage, - claimStatus: - adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, - unreconciled: false, - lastRenewedAt: now - }) - } - if (record.lease.handoffStage === 'new-owner-proving' && record.lease.runtimeKind === 'native') { - // The attempted new owner is proven absent; no writer remains to roll back or readopt. - return updateLease(record, { - ...record.lease, - runtimeFence: adjudication.nextFence, - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null, - processlessAt: null, - claimStatus: 'released', - unreconciled: false, - lastRenewedAt: now, - deathEvidence: adjudication.evidence - }) - } - const provingTuiTarget = - record.lease.handoffStage === 'new-owner-proving' && record.lease.runtimeKind === 'tui' - return updateLease(record, { - ...record.lease, - runtimeKind: provingTuiTarget ? 'native' : record.lease.runtimeKind, - runtimeFence: adjudication.nextFence, - handoffStage: 'old-owner-stopped', - ownerProcess: null, - reservedSpawnToken: null, - processlessAt: null, - claimStatus: 'released', - unreconciled: false, - lastRenewedAt: now, - deathEvidence: adjudication.evidence - }) -} - -function updateLease(record: AgentSessionRecord, lease: AgentSessionRecord['lease']) { - return { ...record, lease, updatedAt: lease.lastRenewedAt } -} diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts index a50fd4cd94ea..45c59e027449 100644 --- a/src/main/runtime/agent-session-restart-lease-transitions.ts +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -15,7 +15,6 @@ import type { AgentSessionHandoffStage, AgentSessionRecord } from '../../shared/agent-session-record' -import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' import { withLease } from './agent-session-lease-transitions' /** Apply one restart adjudication. Never consults deadlines — only proof moves a lease. */ @@ -25,32 +24,11 @@ export function applyAgentSessionRestartAdjudication(args: { now: number }): AgentSessionRecord { const { record } = args - if ( - record.lease.handoffStage === 'old-owner-stopped' && - record.lease.claimStatus === 'released' && - record.lease.ownerProcess === null - ) { - return withLease(record, { - ...record.lease, - unreconciled: false, - lastRenewedAt: args.now - }) - } - if ( - record.lease.handoffStage === 'preparing' || - record.lease.handoffStage === 'new-owner-proving' - ) { - return adjudicateRestartedAgentSessionHandoff(record, args.probe, args.now) - } const adjudication = adjudicateAgentSessionRestart({ lease: record.lease, probe: args.probe, observedAt: args.now }) - if (adjudication.disposition === 'readopt') { - // Why: re-adoption is not a new generation, so the fence does not move. - return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) - } if (adjudication.disposition === 'settlement-pending') { return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) } @@ -67,6 +45,8 @@ export function applyAgentSessionRestartAdjudication(args: { }) } if (adjudication.disposition === 'evicted') { + // A reservation that never proved its handle ran no turn, so no journal settlement is owed. + const settlementOwed = record.lease.handoffStage !== 'new-owner-proving' return withLease(record, { ...record.lease, runtimeFence: adjudication.nextFence, @@ -79,8 +59,12 @@ export function applyAgentSessionRestartAdjudication(args: { lastRenewedAt: args.now, handoffOperationId: null, deathEvidence: adjudication.evidence, - settlementRetryRequired: true, - settlementRetryId: agentSessionRestartEvictionSettlementId(record.lease, adjudication) + ...(settlementOwed + ? { + settlementRetryRequired: true, + settlementRetryId: agentSessionRestartEvictionSettlementId(record.lease, adjudication) + } + : {}) }) } const stage: AgentSessionHandoffStage = diff --git a/src/main/runtime/agent-session-resume-args.test.ts b/src/main/runtime/agent-session-resume-args.test.ts deleted file mode 100644 index db4d0b07b8d3..000000000000 --- a/src/main/runtime/agent-session-resume-args.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveAgentSessionResumeArgs } from './agent-session-resume-args' - -describe('agent session resume arguments', () => { - it('keeps the session creation arguments after mutable defaults change', () => { - expect( - resolveAgentSessionResumeArgs({ - persistedArgs: ['--model', 'claude-created'], - defaultArgs: '--model claude-current', - shell: 'posix' - }) - ).toBe("'--model' 'claude-created'") - }) - - it('keeps an explicit empty snapshot when defaults are toggled off', () => { - expect( - resolveAgentSessionResumeArgs({ - persistedArgs: [], - defaultArgs: '--dangerously-skip-permissions', - shell: 'posix' - }) - ).toBe('') - }) - - it('uses current defaults for legacy records without a snapshot', () => { - expect( - resolveAgentSessionResumeArgs({ - defaultArgs: '--dangerously-skip-permissions', - shell: 'posix' - }) - ).toBe('--dangerously-skip-permissions') - }) -}) diff --git a/src/main/runtime/agent-session-resume-args.ts b/src/main/runtime/agent-session-resume-args.ts deleted file mode 100644 index dc276726dc67..000000000000 --- a/src/main/runtime/agent-session-resume-args.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { AgentSessionLaunchArgs } from '../../shared/agent-session-record' -import { quoteStartupArg, type AgentStartupShell } from '../../shared/tui-agent-startup-shell' - -export function resolveAgentSessionResumeArgs(input: { - requestArgs?: string | null - persistedArgs?: AgentSessionLaunchArgs - defaultArgs?: string | null - shell: AgentStartupShell -}): string | null | undefined { - if (input.requestArgs !== undefined) { - return input.requestArgs - } - if (input.persistedArgs !== undefined) { - return input.persistedArgs.map((arg) => quoteStartupArg(arg, input.shell)).join(' ') - } - return input.defaultArgs -} diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index e840da4944bf..6f5a8bd8198d 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,16 +11,9 @@ import { } from '../../shared/protocol-version' import { fakeClaude } from './claude-structured-fake-connection-test-fixture' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' -import { - CLAUDE_SPAWN_TOKEN_ENV, - claudeProviderHandleLink -} from '../claude/claude-structured-owner-identity' +import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner -} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -45,13 +38,11 @@ const CLIENT = { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } -const { readClaudeTranscriptLeafUuid, resolveSessionFilePath } = vi.hoisted(() => ({ - readClaudeTranscriptLeafUuid: vi.fn(), +const { resolveSessionFilePath } = vi.hoisted(() => ({ resolveSessionFilePath: vi.fn() })) vi.mock('../native-chat/session-file-resolver', () => ({ - readClaudeTranscriptLeafUuid, resolveSessionFilePath })) @@ -132,19 +123,10 @@ function leaseOf(sessionId: string): { return host.deps.store.getRecord(sessionId).lease } -function handoffParams(direction: 'to-native' | 'to-tui', fence: number) { - const fields = { direction, mode: 'now' as const, action: 'start' as const } - return { - envelope: envelope('agentSession.requestHandoff', fields, fence), - ...fields - } -} - let claude: ReturnType let root: string let dispatcher: RpcDispatcher let cleanups: Map void> -let tuiOwner: StructuredTuiOwner | null let transcriptPath: string /** The Claude home the durable record pins; the managed dir under `root` unless a test says otherwise. */ let recordAccountHomePath: string @@ -230,71 +212,8 @@ beforeEach(async () => { transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`) await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true }) resolveSessionFilePath.mockResolvedValue(transcriptPath) - // The production branch proof returns the latest descendant of the prior - // cursor; mirror that contract so structured close does not regress to a - // stale mocked head. - readClaudeTranscriptLeafUuid.mockImplementation( - async (_path: string, _providerSessionId: string, previousLeafUuid?: string | null) => - previousLeafUuid ?? 'init-leaf' - ) claude = fakeClaude(PROVIDER_SESSION) - tuiOwner = null cleanups = new Map() - const handoffTransport: StructuredAgentSessionHandoffTransport = { - hostLabel: 'Scripted Claude host', - launchTui: async ({ record, fence, spawnToken }) => { - const head = record.providerHandleChain.at(-1)?.handle - tuiOwner = { - terminal: { - handle: 'term-claude-tui', - tabId: 'tab-claude-tui', - paneKey: 'tab-claude-tui:leaf-claude-tui', - ptyId: 'pty-claude-tui' - }, - process: { - hostId: 'local', - pid: 7331, - processStartTimeMs: 100, - spawnToken - }, - link: claudeProviderHandleLink({ - sessionId: PROVIDER_SESSION, - leafUuid: head?.provider === 'claude' ? head.leafUuid : null, - resumed: true, - fence, - observedAt: 1 - }), - transcriptPath - } - return tuiOwner - }, - reproveTuiOwner: async ({ owner }) => { - if (owner.link.handle.provider !== 'claude' || !owner.transcriptPath) { - return owner - } - return { - ...owner, - link: claudeProviderHandleLink({ - sessionId: owner.link.handle.sessionId, - leafUuid: await readClaudeTranscriptLeafUuid(owner.transcriptPath), - resumed: true, - fence: owner.link.mintedAtFence, - observedAt: 1 - }) - } - }, - recoverTuiOwner: async () => { - if (!tuiOwner) { - throw new Error('scripted TUI owner missing') - } - return tuiOwner - }, - stopRecoveredOwner: async () => {}, - waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), - waitForTuiIdleOrExit: async () => 'idle', - tuiStatus: () => 'idle', - stopFailedTuiLaunch: async () => {} - } const runtime = { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalStructuredNativeChat: true }), @@ -319,8 +238,7 @@ beforeEach(async () => { resolveEnvironment: async () => shellEnv, resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, resolveClaudeAuthPolicy: () => claudeAuthPolicy, - openClaudeConnection: claude.openConnection, - handoffTransport + openClaudeConnection: claude.openConnection }).then(() => undefined), registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), cleanupSubscription: (id: string) => cleanups.get(id)?.(), @@ -696,8 +614,6 @@ describe('a structured Claude session over agentSession.*', () => { provider: 'claude', leafUuid: 'assistant-leaf' }) - // Claude's marker names a hook row after a turn; close must never adopt it. - readClaudeTranscriptLeafUuid.mockClear().mockResolvedValue('stop-hook-summary-row') const old = claude.live() const resumed = await ok<{ fence: number }>('agentSession.ensure', ensureParams(created.fence)) expect(resumed.fence).toBe(created.fence + 1) @@ -712,13 +628,12 @@ describe('a structured Claude session over agentSession.*', () => { expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( lastCompletedTurn ) - // Open, close, open with no turn in between keeps that leaf and never reads the transcript. + // Open, close, open with no turn in between keeps that leaf. const reopened = await ok<{ fence: number }>('agentSession.ensure', ensureParams(resumed.fence)) expect(reopened.fence).toBe(resumed.fence + 1) expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( lastCompletedTurn ) - expect(readClaudeTranscriptLeafUuid).not.toHaveBeenCalled() }) it('delivers the breakdown a settled turn asks for with no later frame to carry it', async () => { @@ -761,77 +676,4 @@ describe('a structured Claude session over agentSession.*', () => { }) }) }) - - it('completes a scripted native to TUI to native cycle with provider-history rehydration', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - await writeFile( - transcriptPath, - [ - { - type: 'user', - uuid: 'native-user', - message: { role: 'user', content: [{ type: 'text', text: 'NATIVE_USER' }] } - }, - { - type: 'assistant', - uuid: 'native-assistant', - message: { role: 'assistant', content: [{ type: 'text', text: 'NATIVE_ASSISTANT' }] } - }, - { - type: 'user', - uuid: 'tui-user', - message: { role: 'user', content: [{ type: 'text', text: 'TUI_USER' }] } - }, - { - type: 'assistant', - uuid: 'tui-assistant', - message: { role: 'assistant', content: [{ type: 'text', text: 'TUI_ASSISTANT' }] } - }, - { type: 'last-prompt', leafUuid: 'tui-assistant' } - ] - .map((entry) => JSON.stringify(entry)) - .join('\n') - ) - - await ok('agentSession.requestHandoff', handoffParams('to-tui', created.fence)) - const host = getStructuredAgentSessionHost()! - // No poll: the request enqueues the flow on the session's serialized chain before it returns, - // so this status read is already ordered behind it. Polling only added a wall-clock deadline - // that a loaded runner missed, abandoning a live flow into the suite's teardown. - expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) - expect(claude.connections[0]?.closed).toBe(true) - - const tuiFence = ( - host as unknown as { - deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } } - } - ).deps.store.getRecord(SESSION).lease.runtimeFence - readClaudeTranscriptLeafUuid.mockResolvedValueOnce('tui-assistant') - await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence)) - expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) - - const frames = await subscribe() - const texts = itemsOf(frames).map(textOf).filter(Boolean) - expect(texts).toEqual( - expect.arrayContaining(['NATIVE_USER', 'NATIVE_ASSISTANT', 'TUI_USER', 'TUI_ASSISTANT']) - ) - expect(new Set(texts).size).toBe(texts.length) - expect(claude.connections).toHaveLength(2) - expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) - const record = ( - host as unknown as { - deps: { - store: { - getRecord: (id: string) => { - providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] - } - } - } - } - ).deps.store.getRecord(SESSION) - expect(record.providerHandleChain.at(-1)?.handle).toMatchObject({ - provider: 'claude', - leafUuid: 'tui-assistant' - }) - }) }) diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 73669b5a0c34..a29b3b9d0e09 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -22,7 +22,6 @@ export { export { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' export { getTerminalViewColorQueryReplyColors } from './terminal-view-attribute-store' export type { RuntimePtyController } from './runtime-pty-controller-contract' -export { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' export type { IpcMainEvent } from 'electron' diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 81d4dc94dbb1..c366b6e57ef9 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -212,12 +212,6 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate terminalHandle: preAllocatedHandle, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) - if (launchOpts.structuredAgentSessionId) { - dependencies.agentSessionPtyWriteGate.bindPty( - result.id, - launchOpts.structuredAgentSessionId - ) - } const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { pty.runtimeSessionOwned = true diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index 76d67061f4db..a15be97f8a4f 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -17,9 +17,6 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' -import type { AgentSessionLaunchArgs } from '../../shared/agent-session-record' -import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' -import { resolveAgentSessionResumeArgs } from './agent-session-resume-args' export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntimeWithResolveWorktreeRemovalTarget { protected getAgentSessionExecutionNamespace( @@ -90,13 +87,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, - _caller: RuntimeAgentSessionRpcCaller = {}, - handoffAuthority?: { - spawnToken: string - providerRoot: string - sessionId: string - launchArgs?: AgentSessionLaunchArgs - } + _caller: RuntimeAgentSessionRpcCaller = {} ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. @@ -106,11 +97,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) - const namespace = - resolvedNamespace && handoffAuthority - ? { ...resolvedNamespace, providerRoot: handoffAuthority.providerRoot } - : resolvedNamespace + const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) if ( !namespace || !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) @@ -142,20 +129,11 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim agent: request.agent, providerSession: identity.providerSession, cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: resolveAgentSessionResumeArgs({ - requestArgs: request.agentArgs, - persistedArgs: handoffAuthority?.launchArgs, - defaultArgs: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - shell: resolveStartupShell(platform, shell) - }), - agentEnv: { - ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), - ...(handoffAuthority && request.agent === 'codex' - ? { CODEX_HOME: handoffAuthority.providerRoot } - : handoffAuthority && request.agent === 'claude' - ? { CLAUDE_CONFIG_DIR: handoffAuthority.providerRoot } - : {}) - }, + agentArgs: + request.agentArgs !== undefined + ? request.agentArgs + : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), @@ -181,12 +159,6 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim tabId: request.placement?.tabId, leafId: request.placement?.leafId, agentSessionClaim: claim, - ...(handoffAuthority - ? { - launchToken: handoffAuthority.spawnToken, - structuredAgentSessionId: handoffAuthority.sessionId - } - : {}), signal: _caller.signal }) return { diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts similarity index 81% rename from src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts rename to src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index f73c2265eba4..f2143b9772c0 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -1,8 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithStopStructuredSessionProcess } from './orca-runtime-stop-structured-session-process' -import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { agentSessionOwnerBindingsEqual } from '../../shared/claimed-agent-pty-owner-snapshot' -import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' +import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' @@ -12,7 +9,6 @@ import { resolveStructuredAgentSessionAdoptionForCreate } from './structured-agent-session-create-adoption' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' @@ -26,36 +22,7 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' -export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess { - protected async resolveRecoveredStructuredTuiTranscript(input: { - handle: string - paneKey: string - threadId: string - codexHome: string - durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } - }): Promise<{ transcriptPath: string; leafUuid?: never }> { - const assertDurableOwner = (): void => { - const pty = this.getLivePtyForHandle(input.handle)?.pty - if ( - !pty?.connected || - pty.paneKey !== input.paneKey || - pty.incarnationId !== input.durableOwner.incarnationId || - !pty.agentSessionOwners.some((owner) => - agentSessionOwnerBindingsEqual(owner, input.durableOwner.binding) - ) - ) { - throw new Error('The resumed terminal lost its durable owner identity.') - } - } - assertDurableOwner() - const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) - assertDurableOwner() - if (!transcriptPath) { - throw new Error('The agent terminal did not prove the expected Codex rollout.') - } - return { transcriptPath } - } - +export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, agent: 'claude' | 'codex' @@ -72,29 +39,6 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca }) } - protected hasProviderSessionObservationSource(): boolean { - return ( - this.getAgentProviderSessionRowsForPaneFn !== null || - this.getAgentProviderSessionSnapshotFn !== null - ) - } - - protected findAdoptedProviderSession( - paneKey: string, - provider: 'claude' | 'codex', - providerSessionId: string - ): AgentStatusIpcPayload | undefined { - const rows = - this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? - (this.getAgentProviderSessionSnapshotFn?.() ?? []).filter((row) => row.paneKey === paneKey) - return rows - .filter((row) => row.agentType === provider && row.providerSession?.id === providerSessionId) - .reduce( - (latest, row) => (!latest || row.receivedAt > latest.receivedAt ? row : latest), - undefined - ) - } - protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) { const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 3207199f83e2..cf7e8e56db1b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -1,6 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' -import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner' +import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from './orca-runtime-start-tui-idle-visible-read-probe' import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude' import type { RuntimeWorktreePsResult } from '../../shared/runtime-types' import { buildRuntimeWorktreePsSummaries } from './runtime-worktree-ps-summaries' @@ -23,12 +23,9 @@ import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' -import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' -import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' -import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' -export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner { +export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { async getWorktreePs( limit = DEFAULT_WORKTREE_PS_LIMIT, sourceDefaultsSupported = true @@ -174,59 +171,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent firstWorkRenameDeps(this.requireStore(), this) ) }, - ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), - handoffTransport: this.createStructuredAgentSessionHandoffTransport() + ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}) }) } - - protected createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { - const machineName = this.machineName - return { - // Why a getter: "Agent is open in terminal on X" must name this host the way paired devices - // see it, including a Settings rename after the transport was built. - get hostLabel() { - return machineName.read() - }, - launchTui: this.createStructuredAgentSessionLaunchTuiCallback(), - waitForTuiExit: async (owner) => { - await this.waitForStructuredTuiOwnerExit(owner) - return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} - }, - waitForTuiIdleOrExit: async (owner, signal) => { - return this.waitForStructuredTuiIdleOrExit(owner, signal) - }, - reproveTuiOwner: this.createStructuredAgentSessionReproveTuiOwnerCallback(), - recoverTuiOwner: this.createStructuredAgentSessionRecoverTuiOwnerCallback(), - probeRecoveredOwner: async (record) => { - const identity = record.lease.ownerProcess - if (!identity) { - return 'dead' - } - const proof = await probeAgentSessionProcessIdentity({ identity }) - if (proof.outcome === 'identity-matched' && proof.matchedOn.length > 0) { - return 'live' - } - if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { - return 'dead' - } - return 'unknown' - }, - stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), - tuiStatus: (owner) => this.structuredTuiStatus(owner), - closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), - revealNativeSession: async ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { - if (adoptedTerminal || (agent !== 'codex' && agent !== 'claude')) { - return - } - await this.publishStructuredAgentSessionTab({ - workspaceId, - sessionId, - agent, - activate: false - }) - this.notifier?.focusEditorTab?.(structuredAgentSessionTabId(sessionId), workspaceId) - }, - stopFailedTuiLaunch: async (owner) => void (await this.closeStructuredTuiOwner(owner)) - } - } } diff --git a/src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts b/src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts deleted file mode 100644 index 12e19f2b471e..000000000000 --- a/src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts +++ /dev/null @@ -1,197 +0,0 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' -import { - PROCESS_START_TIME_TOLERANCE_MS, - probeAgentSessionProcessIdentity -} from './agent-session-process-identity-probe' -import type { StructuredTuiOwner } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' -import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' -import { randomUUID } from 'node:crypto' -import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' - -export class OrcaRuntimeWithProveRecoveredStructuredTuiPtyProcess extends OrcaRuntimeWithGetWorktreePs { - protected async proveRecoveredStructuredTuiPtyProcess( - pty: RuntimePtyWorktreeRecord, - identity: NonNullable, - provider: 'codex' | 'claude' = 'codex' - ): Promise { - const listings = await this.ptyController?.listProcesses?.(pty.connectionId) - const listed = listings?.find( - (candidate) => candidate.id === pty.ptyId && candidate.incarnationId === pty.incarnationId - ) - if (!listed?.rootProcessId || identity.processStartTimeMs === null) { - console.warn('[structured-tui-recovery] claimed PTY process mismatch', { - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - rootProcessId: listed?.rootProcessId ?? null, - mismatchedFields: [ - ...(!listed?.rootProcessId ? ['root-process-id'] : []), - ...(identity.processStartTimeMs === null ? ['persisted-process-start-time'] : []) - ] - }) - return false - } - try { - const observed = await readStructuredTuiProcessIdentity({ - hostId: identity.hostId, - rootPid: listed.rootProcessId, - spawnToken: identity.spawnToken, - agent: provider - }) - const matched = { - hostId: observed.hostId === identity.hostId, - pid: observed.pid === identity.pid, - processStartTime: - observed.processStartTimeMs !== null && - Math.abs(observed.processStartTimeMs - identity.processStartTimeMs) <= - PROCESS_START_TIME_TOLERANCE_MS - } - if (!Object.values(matched).every(Boolean)) { - console.warn('[structured-tui-recovery] claimed PTY process mismatch', { - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - rootProcessId: listed.rootProcessId, - persisted: { - hostId: identity.hostId, - pid: identity.pid, - processStartTimeMs: identity.processStartTimeMs - }, - observed: { - hostId: observed.hostId, - pid: observed.pid, - processStartTimeMs: observed.processStartTimeMs - }, - mismatchedFields: Object.entries(matched) - .filter(([, matches]) => !matches) - .map(([field]) => field) - }) - } - return Object.values(matched).every(Boolean) - } catch (error) { - console.warn('[structured-tui-recovery] claimed PTY process mismatch', { - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - rootProcessId: listed.rootProcessId, - mismatchedFields: [`${provider}-child-proof`], - error: error instanceof Error ? error.message : String(error) - }) - return false - } - } - - protected async closeStructuredTuiOwner( - owner: StructuredTuiOwner - ): Promise<{ transcriptPath?: string }> { - if (this.ptysById.get(owner.terminal.ptyId)?.connected) { - const current = this.refreshStructuredTuiOwnerBinding(owner) - try { - await this.closeTerminal(current.terminal.handle) - } catch (error) { - if (this.ptysById.get(owner.terminal.ptyId)?.connected) { - throw error - } - } - } - await this.waitForStructuredTuiOwnerExit(owner) - return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} - } - - // The new exact `codex resume ` child proves the resumed owner without - // a first turn; the pinned rollout then binds its durable transcript. - protected async waitForAdoptedStructuredTuiProof(input: { - owner: StructuredTuiOwner - threadId: string - codexHome: string - }): Promise<{ transcriptPath: string; leafUuid?: never }> { - const assertPaneIdentity = (): void => { - const pty = this.ptysById.get(input.owner.terminal.ptyId) - if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { - throw new Error('The adopted terminal lost its pane identity.') - } - } - assertPaneIdentity() - const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) - if (!transcriptPath) { - throw new Error('The agent terminal did not prove the expected Codex rollout.') - } - assertPaneIdentity() - const processProof = await probeAgentSessionProcessIdentity({ identity: input.owner.process }) - if (processProof.outcome !== 'identity-matched' || processProof.matchedOn.length === 0) { - throw new Error('The resumed Codex process could not be re-proved.') - } - return { transcriptPath } - } - - protected refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { - const pty = this.ptysById.get(owner.terminal.ptyId) - if (!pty?.connected) { - throw new Error('The owning agent terminal lost its launch identity.') - } - const handle = this.issueStructuredTuiPtyHandle(pty) - if (handle === owner.terminal.handle) { - return owner - } - return { ...owner, terminal: { ...owner.terminal, handle } } - } - - protected issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string { - const existingHandle = this.findHandleForPtyRecord(pty.ptyId) - if (existingHandle) { - this.handleByPtyId.set(pty.ptyId, existingHandle) - return existingHandle - } - const handle = `term_${randomUUID()}` - const syntheticId = `pty:${pty.ptyId}` - this.syntheticTerminalHandles.add(handle) - this.handles.set(handle, { - handle, - runtimeId: this.runtimeId, - rendererGraphEpoch: this.rendererGraphEpoch, - worktreeId: pty.worktreeId, - tabId: syntheticId, - leafId: syntheticId, - ptyId: pty.ptyId, - ptyGeneration: 0 - }) - this.handleByPtyId.set(pty.ptyId, handle) - return handle - } - - protected async waitForStructuredTuiPtyExit(ptyId: string): Promise { - const deadline = Date.now() + 5_000 - while (this.ptysById.get(ptyId)?.connected === true) { - if (Date.now() >= deadline) { - throw new Error('terminal_handle_stale') - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - } - - protected async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise { - await waitForStructuredTuiExitProof({ - identity: owner.process, - waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId) - }) - } - - protected async waitForStructuredTuiIdleOrExit( - owner: StructuredTuiOwner, - signal: AbortSignal - ): Promise<'idle' | 'exited' | null> { - const deadline = Date.now() + 250 - while (!signal.aborted && Date.now() < deadline) { - if (!this.ptysById.get(owner.terminal.ptyId)?.connected) { - await this.waitForStructuredTuiOwnerExit(owner) - return 'exited' - } - if (this.structuredTuiStatus(owner) === 'idle') { - return 'idle' - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - return null - } -} diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f641b7bb036f..d53b465d97d5 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -1,6 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' -import { OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript } from './orca-runtime-resolve-recovered-structured-tui-transcript' +import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-runtime-get-structured-agent-session-create-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' @@ -27,7 +27,7 @@ import { isWslUncPath } from '../../shared/wsl-paths' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript { +export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { async replaceStructuredAgentSessionTab(replacement: ConversationReplacement): Promise { const prior = this.mobileSessionTabsByWorktree.get(replacement.workspaceId) const next = prior ? replaceConversationInSnapshot(prior, replacement) : null diff --git a/src/main/runtime/orca-runtime-stop-structured-session-process.ts b/src/main/runtime/orca-runtime-stop-structured-session-process.ts deleted file mode 100644 index 3e2cab8d726e..000000000000 --- a/src/main/runtime/orca-runtime-stop-structured-session-process.ts +++ /dev/null @@ -1,204 +0,0 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithProveRecoveredStructuredTuiPtyProcess } from './orca-runtime-prove-recovered-structured-tui-pty-process' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' -import type { StructuredTuiOwner } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' -import { buildTerminalWaitText } from './terminal-wait-tail-state' -import { - detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview -} from './terminal-wait-detection' -import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { proveCodexTuiRollout } from '../codex/codex-tui-rollout-proof' -import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' -import { - readClaudeTranscriptLeafUuid, - resolveSessionFilePath -} from '../native-chat/session-file-resolver' -import { isPathWithinDirectory } from './orca-runtime-core' - -export class OrcaRuntimeWithStopStructuredSessionProcess extends OrcaRuntimeWithProveRecoveredStructuredTuiPtyProcess { - protected async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { - const identity = record.lease.ownerProcess - if (!identity) { - return - } - const proof = await probeAgentSessionProcessIdentity({ identity }) - if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { - return - } - if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { - throw new Error('The recovered owner process could not be stopped safely.') - } - try { - process.kill(identity.pid, 'SIGTERM') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { - throw error - } - return - } - const deadline = Date.now() + 15_000 - while (Date.now() < deadline) { - const current = await probeAgentSessionProcessIdentity({ identity }) - if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { - return - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - // SIGTERM is only a request. Escalate once, then require an independent - // absence probe before allowing the lease transition to proceed. - try { - process.kill(identity.pid, 'SIGKILL') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { - throw error - } - return - } - const forcedDeadline = Date.now() + 5_000 - while (Date.now() < forcedDeadline) { - const current = await probeAgentSessionProcessIdentity({ identity }) - if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { - return - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - throw new Error('The recovered owner process did not exit after forced termination.') - } - - protected structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' { - const pty = this.ptysById.get(owner.terminal.ptyId) - const paneKey = pty?.paneKey ?? owner.terminal.paneKey - const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey) - if (explicit) { - return explicit.status === 'idle' ? 'idle' : 'busy' - } - if (pty?.connected) { - const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - const blocked = detectTerminalWaitBlockedReason(text) !== null - if (!blocked && isKnownReadyPromptPreview(text)) { - return 'idle' - } - return hasStructuredTuiIdleEvidence({ - blocked, - status: pty.lastAgentStatus, - statusObservedLive: pty.lastAgentStatusObservedLive - }) - ? 'idle' - : 'busy' - } - return 'busy' - } - - protected async waitForStructuredTuiProof(input: { - handle: string - paneKey: string - threadId: string - spawnToken: string - codexHome: string - sessionId: string - }): Promise<{ transcriptPath?: string; leafUuid?: never }> { - const readBoundPty = (): RuntimePtyWorktreeRecord => { - const pty = this.getLivePtyForHandle(input.handle)?.pty - if ( - !pty?.connected || - pty.paneKey !== input.paneKey || - pty.launchAgent !== 'codex' || - pty.launchToken !== input.spawnToken - ) { - throw new Error('The resumed terminal lost its launch identity.') - } - return pty - } - const initialPty = readBoundPty() - const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0 - return proveCodexTuiRollout({ - codexHome: input.codexHome, - threadId: input.threadId, - kittyKeyboardFlags, - readOutput: () => { - const pty = readBoundPty() - return { - text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), - lastOutputAt: pty.lastOutputAt - } - }, - write: (data) => { - const pty = readBoundPty() - return ( - this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, { - sessionId: input.sessionId, - spawnToken: input.spawnToken - }) ?? false - ) - } - }) - } - - protected async waitForStructuredClaudeTuiProof(input: { - handle: string - paneKey: string - sessionId: string - previousLeafUuid: string | null - projectsDir: string - /** Set when this call launched a new Claude process; a cached transcript marker is not enough. */ - spawnToken?: string - minimumProviderSessionReceivedAt?: number - }): Promise<{ transcriptPath: string; leafUuid: string }> { - const deadline = Date.now() + 15_000 - let incompleteTail: ClaudeTranscriptTailIncompleteError | null = null - while (Date.now() < deadline) { - const pty = this.getLivePtyForHandle(input.handle)?.pty - if (!pty?.connected || pty.paneKey !== input.paneKey || pty.launchAgent !== 'claude') { - throw new Error('The resumed Claude terminal lost its launch identity.') - } - if (input.spawnToken) { - if (!this.hasProviderSessionObservationSource()) { - throw new Error('The Claude terminal could not prove its fresh provider session.') - } - const observedProviderRow = this.findAdoptedProviderSession( - input.paneKey, - 'claude', - input.sessionId - ) - if ( - !observedProviderRow || - observedProviderRow.launchToken !== input.spawnToken || - (input.minimumProviderSessionReceivedAt !== undefined && - observedProviderRow.receivedAt < input.minimumProviderSessionReceivedAt) - ) { - await new Promise((resolve) => setTimeout(resolve, 100)) - continue - } - } - const transcriptPath = await resolveSessionFilePath('claude', input.sessionId, { - claudeProjectsDir: input.projectsDir - }) - if (transcriptPath) { - if (!isPathWithinDirectory(input.projectsDir, transcriptPath)) { - throw new Error('The Claude terminal reported a transcript outside its account root.') - } - try { - const leafUuid = await readClaudeTranscriptLeafUuid( - transcriptPath, - input.sessionId, - input.previousLeafUuid - ) - return { transcriptPath, leafUuid } - } catch (error) { - if (!(error instanceof ClaudeTranscriptTailIncompleteError)) { - throw error - } - incompleteTail = error - } - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - if (incompleteTail) { - throw incompleteTail - } - throw new Error('The agent terminal did not prove the expected Claude session.') - } -} diff --git a/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts b/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts deleted file mode 100644 index 89836d1e0270..000000000000 --- a/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts +++ /dev/null @@ -1,154 +0,0 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from './orca-runtime-start-tui-idle-visible-read-probe' -import { join } from 'node:path' -import type { StructuredTuiOwner } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' -import { readCodexResumeProcessIdentity } from '../codex/codex-resume-process-proof' -import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' -import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' -import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' -import { StructuredTuiLaunchCleanupError } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' - -export class OrcaRuntimeWithStructuredAgentSessionLaunchTui extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { - protected createStructuredAgentSessionLaunchTuiCallback() { - return async ({ record, fence, spawnToken, onSpawned }) => { - const head = record.providerHandleChain.at(-1) - if (!head || (head.handle.provider !== 'codex' && head.handle.provider !== 'claude')) { - throw new Error('agent_session_identity_required') - } - const provider = head.handle.provider - const providerSessionId = provider === 'claude' ? head.handle.sessionId : head.handle.threadId - const launchStartedAt = Date.now() - const launched = await this.ensureAgentSession( - { - kind: 'explicit', - worktree: `id:${record.location.workspaceId}`, - agent: provider, - providerSession: { key: 'session_id', id: providerSessionId }, - ...(record.options ? { launchPreferences: record.options } : {}), - presentation: 'background' - }, - {}, - { - spawnToken, - providerRoot: record.accountHome.path, - sessionId: record.sessionId, - ...(record.launchArgs !== undefined ? { launchArgs: record.launchArgs } : {}) - } - ) - const terminal = launched.terminal - let spawnedOwner: StructuredTuiOwner | null = null - let ptyId: string | undefined - try { - if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { - throw new Error('The resumed terminal did not publish a process identity.') - } - ptyId = terminal.ptyId - spawnedOwner = this.refreshStructuredTuiOwnerBinding({ - terminal: { - handle: terminal.handle, - tabId: terminal.tabId, - paneKey: terminal.paneKey, - ptyId: terminal.ptyId - }, - process: - provider === 'codex' - ? await readCodexResumeProcessIdentity({ - hostId: record.location.executionHostId, - rootPid: terminal.processId, - spawnToken, - threadId: head.handle.threadId - }) - : await readStructuredTuiProcessIdentity({ - hostId: record.location.executionHostId, - rootPid: terminal.processId, - spawnToken, - agent: provider - }), - link: - provider === 'codex' - ? codexProviderHandleLink({ - threadId: head.handle.threadId, - resumed: true, - fence, - observedAt: Date.now() - }) - : claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: head.handle.leafUuid, - resumed: true, - fence, - observedAt: Date.now() - }) - }) - await onSpawned?.(spawnedOwner) - await this.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 30000 }) - const proof = - provider === 'codex' - ? await this.waitForAdoptedStructuredTuiProof({ - owner: spawnedOwner, - threadId: head.handle.threadId, - codexHome: record.accountHome.path - }) - : await this.waitForStructuredClaudeTuiProof({ - handle: terminal.handle, - paneKey: terminal.paneKey, - sessionId: head.handle.sessionId, - previousLeafUuid: head.handle.leafUuid, - projectsDir: join(record.accountHome.path, 'projects'), - spawnToken, - minimumProviderSessionReceivedAt: launchStartedAt - }) - const revealed = await this.focusTerminal(terminal.handle) - return this.refreshStructuredTuiOwnerBinding({ - ...spawnedOwner, - link: - provider === 'claude' - ? claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: proof.leafUuid ?? head.handle.leafUuid, - resumed: true, - fence, - observedAt: Date.now() - }) - : spawnedOwner.link, - terminal: { - handle: terminal.handle, - tabId: revealed.tabId, - paneKey: terminal.paneKey, - ptyId: terminal.ptyId - }, - process: spawnedOwner.process, - ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}), - historySource: 'provider-resume' - }) - } catch (error) { - let closeError: unknown = null - try { - await this.closeTerminal(terminal.handle) - } catch (cleanupFailure) { - closeError = cleanupFailure - } - try { - if (spawnedOwner) { - await this.waitForStructuredTuiOwnerExit(spawnedOwner) - } else if (ptyId) { - await this.waitForStructuredTuiPtyExit(ptyId) - } else { - throw new Error('The failed terminal did not publish a PTY identity.') - } - } catch (exitFailure) { - throw new StructuredTuiLaunchCleanupError( - error, - closeError === null - ? exitFailure - : new AggregateError( - [closeError, exitFailure], - 'Structured TUI cleanup could not prove process exit.' - ) - ) - } - throw error - } - } - } -} diff --git a/src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts b/src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts deleted file mode 100644 index dcd53edf9643..000000000000 --- a/src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts +++ /dev/null @@ -1,204 +0,0 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithStructuredAgentSessionReproveTuiOwner } from './orca-runtime-structured-agent-session-reprove-tui-owner' -import { join } from 'node:path' -import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' -import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' -import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { - resolveTerminalSessionWorktreeId, - runtimeWorktreeIdsEqual -} from './runtime-worktree-path-identity' -import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity' -import { makePaneKey } from '../../shared/stable-pane-id' -import { recordPtySurface, SURFACE_CLAIM_WITHOUT_STANDING } from './pty-recorded-surface-topology' -import { evaluateStructuredTuiRecoveryClaim } from './structured-tui-recovery-claim-match' -import { - cloneAgentSessionOwnerBinding, - scopedAgentSessionClaimsEqual -} from '../../shared/claimed-agent-pty-owner-snapshot' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' - -export class OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner extends OrcaRuntimeWithStructuredAgentSessionReproveTuiOwner { - protected createStructuredAgentSessionRecoverTuiOwnerCallback() { - return async (record) => { - const identity = record.lease.ownerProcess - const head = record.providerHandleChain.at(-1) - if ( - !identity || - !head || - (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') - ) { - throw new Error('agent_session_identity_required') - } - const provider = head.handle.provider - const providerSessionId = provider === 'claude' ? head.handle.sessionId : head.handle.threadId - let candidate = [...this.ptysById.values()].find( - (pty) => - pty.connected && - pty.launchToken === identity.spawnToken && - pty.launchAgent === provider && - pty.tabId && - pty.paneKey - ) - let handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null - let durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } | undefined - if (!candidate) { - const workspace = await this.resolveTerminalWorkspaceLaunchScope( - `id:${record.location.workspaceId}` - ) - const baseNamespace = this.getAgentSessionExecutionNamespace(workspace, provider) - if (!baseNamespace || !runtimeWorktreeIdsEqual(workspace.id, record.location.workspaceId)) { - throw new Error('agent_session_identity_required') - } - const claim = this.agentSessionClaimSigner.createClaim({ - namespace: { ...baseNamespace, providerRoot: record.accountHome.path }, - identity: canonicalizeAgentSessionIdentity(provider, { - key: 'session_id', - id: providerSessionId - }), - canonicalWorktreeId: workspace.id - }) - const candidateEvaluations = [...this.ptysById.values()].flatMap((pty) => - pty.agentSessionOwners.map((owner) => { - const session = this.getWorkspaceSessionForWorktree(owner.surface.worktreeId) - const sessionWorktreeId = session - ? resolveTerminalSessionWorktreeId(session, owner.surface.worktreeId) - : null - const persistedTab = sessionWorktreeId - ? session?.tabsByWorktree[sessionWorktreeId]?.find( - (candidate) => candidate.id === owner.surface.tabId - ) - : null - const paneKey = makePaneKey(owner.surface.tabId, owner.surface.leafId) - const persisted = { - sessionResolved: Boolean(session && sessionWorktreeId), - tabPresent: Boolean(persistedTab), - ptyId: - session?.terminalLayoutsByTabId[owner.surface.tabId]?.ptyIdsByLeafId?.[ - owner.surface.leafId - ] ?? null, - incarnationId: session?.terminalPtyIncarnationsByPaneKey?.[paneKey] ?? null - } - const evaluation = evaluateStructuredTuiRecoveryClaim( - { - expectedWorkspaceId: workspace.id, - claimMatches: scopedAgentSessionClaimsEqual(owner.claim, claim), - pty: { - connected: pty.connected, - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - worktreeId: pty.worktreeId - }, - owner: { phase: owner.phase, ptyId: owner.ptyId, surface: owner.surface }, - persisted - }, - runtimeWorktreeIdsEqual - ) - return { pty, owner, persisted, evaluation } - }) - ) - const recoveredCandidates = candidateEvaluations - .filter(({ evaluation }) => evaluation.matches) - .map(({ pty, owner }) => ({ pty, owner })) - const recovered = recoveredCandidates.length === 1 ? recoveredCandidates[0] : null - if (!recovered) { - console.warn('[structured-tui-recovery] claim mismatch', { - sessionId: record.sessionId, - expectedWorkspaceId: workspace.id, - persistedOwnerProcess: { - hostId: identity.hostId, - pid: identity.pid, - processStartTimeMs: identity.processStartTimeMs, - spawnTokenPresent: identity.spawnToken.length > 0 - }, - candidates: candidateEvaluations.map(({ pty, owner, persisted, evaluation }) => ({ - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - worktreeId: pty.worktreeId, - ownerSurface: owner.surface, - persisted, - mismatchedFields: evaluation.mismatchedFields - })) - }) - } - if ( - !recovered || - !(await this.proveRecoveredStructuredTuiPtyProcess(recovered.pty, identity, provider)) - ) { - throw new Error('The owning agent terminal could not be recovered.') - } - candidate = recovered.pty - // The owner binding is persisted evidence, so it names the pane without claiming the graph - // still holds it; the guard below needs the names, not the standing. - recordPtySurface( - candidate, - recovered.owner.surface.tabId, - makePaneKey(recovered.owner.surface.tabId, recovered.owner.surface.leafId), - SURFACE_CLAIM_WITHOUT_STANDING - ) - handle = this.issuePtyHandle(candidate) - const recoveredIncarnationId = candidate.incarnationId - if (handle && recoveredIncarnationId) { - durableOwner = { - binding: cloneAgentSessionOwnerBinding(recovered.owner), - incarnationId: recoveredIncarnationId - } - } - } - if (!candidate?.tabId || !candidate.paneKey || !handle) { - throw new Error('The owning agent terminal could not be recovered.') - } - agentSessionPtyWriteGate.bindPty(candidate.ptyId, record.sessionId) - const proof = - provider === 'codex' - ? durableOwner - ? await this.resolveRecoveredStructuredTuiTranscript({ - handle, - paneKey: candidate.paneKey, - threadId: head.handle.threadId, - codexHome: record.accountHome.path, - durableOwner - }) - : await this.waitForStructuredTuiProof({ - handle, - paneKey: candidate.paneKey, - threadId: head.handle.threadId, - spawnToken: identity.spawnToken, - codexHome: record.accountHome.path, - sessionId: record.sessionId - }) - : await this.waitForStructuredClaudeTuiProof({ - handle, - paneKey: candidate.paneKey, - sessionId: head.handle.sessionId, - previousLeafUuid: head.handle.leafUuid, - projectsDir: join(record.accountHome.path, 'projects') - }) - return { - terminal: { - handle, - tabId: candidate.tabId, - paneKey: candidate.paneKey, - ptyId: candidate.ptyId - }, - process: identity, - link: - provider === 'codex' - ? codexProviderHandleLink({ - threadId: head.handle.threadId, - resumed: true, - fence: record.lease.runtimeFence, - observedAt: Date.now() - }) - : claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: proof.leafUuid ?? head.handle.leafUuid, - resumed: true, - fence: record.lease.runtimeFence, - observedAt: Date.now() - }), - transcriptPath: proof.transcriptPath - } - } - } -} diff --git a/src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts b/src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts deleted file mode 100644 index fe981ff8121c..000000000000 --- a/src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts +++ /dev/null @@ -1,75 +0,0 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithStructuredAgentSessionLaunchTui } from './orca-runtime-structured-agent-session-launch-tui' -import { join } from 'node:path' -import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' -import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' -import { - agentSessionProviderHandleRoot, - agentSessionProviderHandlesEqual -} from '../../shared/agent-session-provider-handle' -import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' - -export class OrcaRuntimeWithStructuredAgentSessionReproveTuiOwner extends OrcaRuntimeWithStructuredAgentSessionLaunchTui { - protected createStructuredAgentSessionReproveTuiOwnerCallback() { - return async ({ record, owner }) => { - const current = this.refreshStructuredTuiOwnerBinding(owner) - const persisted = record.lease.ownerProcess - if ( - !persisted || - persisted.hostId !== current.process.hostId || - persisted.pid !== current.process.pid || - persisted.processStartTimeMs !== current.process.processStartTimeMs || - persisted.spawnToken !== current.process.spawnToken - ) { - throw new Error('The owning terminal does not match the persisted launch identity.') - } - const proof = await probeAgentSessionProcessIdentity({ identity: current.process }) - if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { - throw new Error( - `The owning ${current.link.handle.provider} child process could not be re-proved.` - ) - } - const head = record.providerHandleChain.at(-1) - const sameProviderIdentity = - head && - (current.link.handle.provider === 'claude' - ? agentSessionProviderHandleRoot(current.link.handle) === - agentSessionProviderHandleRoot(head.handle) - : (record.lease.provenHandleLinkId === null || - current.link.linkId === record.lease.provenHandleLinkId) && - agentSessionProviderHandlesEqual(current.link.handle, head.handle)) - if (!sameProviderIdentity) { - throw new Error('agent_session_identity_required') - } - if (current.link.handle.provider === 'claude' && head.handle.provider === 'claude') { - const proof = await this.waitForStructuredClaudeTuiProof({ - handle: current.terminal.handle, - paneKey: current.terminal.paneKey, - sessionId: head.handle.sessionId, - previousLeafUuid: head.handle.leafUuid, - projectsDir: join(record.accountHome.path, 'projects') - }) - return { - ...current, - link: claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: proof.leafUuid, - resumed: true, - fence: record.lease.runtimeFence, - observedAt: Date.now() - }), - transcriptPath: proof.transcriptPath - } - } - if (current.transcriptPath || current.link.handle.provider !== 'codex') { - return current - } - if (head.handle.provider !== 'codex') { - return current - } - const threadId = head.handle.threadId - const transcriptPath = await resolvePinnedCodexRolloutProof(record.accountHome.path, threadId) - return transcriptPath ? { ...current, transcriptPath } : current - } - } -} diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts deleted file mode 100644 index d15f754b244e..000000000000 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ /dev/null @@ -1,730 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' -import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' -import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' -import { OrcaRuntimeService } from './orca-runtime' - -const { - probeAgentSessionProcessIdentity, - proveCodexTuiRollout, - readClaudeTranscriptLeafUuid, - readStructuredTuiProcessIdentity, - resolveSessionFilePath, - resolvePinnedCodexRolloutProof -} = vi.hoisted(() => ({ - probeAgentSessionProcessIdentity: vi.fn(), - proveCodexTuiRollout: vi.fn(), - readClaudeTranscriptLeafUuid: vi.fn(), - readStructuredTuiProcessIdentity: vi.fn(), - resolveSessionFilePath: vi.fn(), - resolvePinnedCodexRolloutProof: vi.fn() -})) - -vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) -vi.mock('../codex/codex-tui-rollout-proof', () => ({ - proveCodexTuiRollout, - resolvePinnedCodexRolloutProof -})) -vi.mock('../native-chat/session-file-resolver', () => ({ - readClaudeTranscriptLeafUuid, - resolveSessionFilePath -})) -vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ - ...(await importOriginal()), - probeAgentSessionProcessIdentity -})) - -const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' - -function notifier(revealTerminalSession: ReturnType) { - return { - worktreesChanged: vi.fn(), - reposChanged: vi.fn(), - activateWorktree: vi.fn(), - createTerminal: vi.fn(), - revealTerminalSession, - splitTerminal: vi.fn(), - renameTerminal: vi.fn(), - focusTerminal: vi.fn(), - closeTerminal: vi.fn(), - sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), - terminalDriverChanged: vi.fn() - } -} - -describe('structured TUI launch tab binding', () => { - it('recovers a live TUI from durable owner inventory in a fresh runtime', async () => { - const namespace = { - machine: 'native:test', - principal: 'uid:1', - container: 'native', - providerRoot: '/tmp/codex-home' - } - const signer = createEphemeralAgentSessionClaimSigner('profile-test') - const claim = signer.createClaim({ - namespace, - identity: { agent: 'codex', providerSession: { key: 'session_id', id: 'thread-1' } }, - canonicalWorktreeId: WORKTREE_ID - }) - const terminalHandle = 'term_cold_owner' - const leafId = '23013912-13f8-44e5-818f-d40a1ff4e8c5' - resolvePinnedCodexRolloutProof.mockResolvedValue('/tmp/codex-home/sessions/thread-1.jsonl') - const writeAgentSessionProof = vi.fn(() => false) - const runtime = new OrcaRuntimeService(undefined, undefined, { - agentSessionClaimSigner: signer - }) - runtime.setPtyController({ - listProcesses: vi.fn(async () => [ - { - id: 'pty-cold-owner', - incarnationId: 'incarnation-1', - cwd: '/tmp/structured-handoff', - title: 'codex', - worktreeId: WORKTREE_ID, - terminalHandle, - agentSessionOwners: [ - { - claim, - generation: 'generation-1', - phase: 'live' as const, - ptyId: 'pty-cold-owner', - surface: { - worktreeId: WORKTREE_ID, - tabId: 'tab-cold-owner', - leafId, - terminalHandle - } - } - ] - } - ]), - write: () => true, - kill: () => true, - writeAgentSessionProof, - getForegroundProcess: async () => null - }) - const internal = runtime as unknown as { - createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport - refreshMobileSessionPtyRecords(): Promise | null> - listResolvedWorktrees(): Promise - resolveTerminalWorkspaceLaunchScope(): Promise<{ - id: string - path: string - connectionId: null - repo: null - folderWorkspace: null - }> - getAgentSessionExecutionNamespace(): typeof namespace - ptysById: Map< - string, - { - launchToken: string | null - launchAgent: string | null - agentSessionOwners: unknown[] - tabId?: string | null - paneKey?: string | null - } - > - } - internal.listResolvedWorktrees = vi.fn(async () => [ - { id: WORKTREE_ID, repoId: 'repo-1', path: '/tmp/structured-handoff' } - ]) - internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ - id: WORKTREE_ID, - path: '/tmp/structured-handoff', - connectionId: null, - repo: null, - folderWorkspace: null - })) - internal.getAgentSessionExecutionNamespace = () => namespace - proveCodexTuiRollout.mockResolvedValueOnce({ - transcriptPath: '/tmp/codex-home/sessions/thread-1.jsonl' - }) - probeAgentSessionProcessIdentity.mockResolvedValue({ - outcome: 'identity-matched', - matchedOn: ['process-start-time'] - }) - - await internal.refreshMobileSessionPtyRecords() - const coldPty = internal.ptysById.get('pty-cold-owner')! - expect(coldPty).toMatchObject({ launchToken: null, launchAgent: null }) - expect(coldPty.agentSessionOwners).toHaveLength(1) - const runtimeId = (runtime as unknown as { runtimeId: string }).runtimeId - ;( - runtime as unknown as { - handles: Map< - string, - { - handle: string - runtimeId: string - rendererGraphEpoch: number - worktreeId: string - tabId: string - leafId: string - ptyId: string - ptyGeneration: number - } - > - } - ).handles.set(terminalHandle, { - handle: terminalHandle, - runtimeId, - rendererGraphEpoch: 0, - worktreeId: WORKTREE_ID, - tabId: 'pty:pty-cold-owner', - leafId: 'pty:pty-cold-owner', - ptyId: 'pty-cold-owner', - ptyGeneration: 0 - }) - coldPty.tabId = 'tab-cold-owner' - coldPty.paneKey = `tab-cold-owner:${leafId}` - coldPty.launchToken = 'spawn-token' - coldPty.launchAgent = 'codex' - - const owner = await internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner({ - sessionId: 'session-1', - location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, - accountHome: { variable: 'CODEX_HOME', path: namespace.providerRoot }, - providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], - lease: { - ownerProcess: { - hostId: 'local', - pid: 4243, - processStartTimeMs: 10, - spawnToken: 'spawn-token' - }, - runtimeFence: 3 - } - } as never) - - expect(owner.terminal).toEqual({ - handle: terminalHandle, - tabId: 'tab-cold-owner', - paneKey: `tab-cold-owner:${leafId}`, - ptyId: 'pty-cold-owner' - }) - expect(proveCodexTuiRollout).toHaveBeenCalledWith( - expect.objectContaining({ - codexHome: namespace.providerRoot, - threadId: 'thread-1', - readOutput: expect.any(Function), - write: expect.any(Function) - }) - ) - expect(resolvePinnedCodexRolloutProof).not.toHaveBeenCalled() - expect(writeAgentSessionProof).not.toHaveBeenCalled() - expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1') - agentSessionPtyWriteGate.unbindPty('pty-cold-owner') - }) - - it('rebuilds a Claude proving link from current launch-token-bound hook evidence', async () => { - const paneKey = 'tab-claude:leaf-claude' - const spawnToken = 'claude-restart-token' - const sessionId = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' - const transcriptPath = '/tmp/claude-home/projects/worktree/session.jsonl' - const attestAgentHookCompatibilityAuthority = vi.fn(() => ({ - paneKey, - source: 'hydrated_commitment' as const - })) - const runtime = new OrcaRuntimeService(null, undefined, { - attestAgentHookCompatibilityAuthority, - getAgentProviderSessionRowsForPane: () => [ - { - paneKey, - connectionId: null, - state: 'done', - prompt: '', - agentType: 'claude', - receivedAt: Date.now() + 1000, - stateStartedAt: 10, - providerSession: { key: 'session_id', id: sessionId, transcriptPath } - } - ] - }) - const internal = runtime as unknown as { - createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport - ptysById: Map - restoredOrchestrationAuthorityByPtyId: Map - } - internal.ptysById.set('pty-claude', { - ptyId: 'pty-claude', - worktreeId: WORKTREE_ID, - connectionId: null, - tabId: 'tab-claude', - paneKey, - launchToken: spawnToken, - launchAgent: 'claude', - connected: true - }) - resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/session.jsonl') - readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-before-resume') - const record = { - sessionId: 'session-1', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, - providerHandleChain: [ - { - linkId: 'claude-old', - handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' }, - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ], - lease: { - runtimeFence: 3, - ownerProcess: { - hostId: 'local', - pid: 4343, - processStartTimeMs: 20, - spawnToken - }, - provenHandleLinkId: null - } - } as never - probeAgentSessionProcessIdentity.mockResolvedValue({ - outcome: 'identity-matched', - matchedOn: ['process-start-time'] - }) - - const transport = internal.createStructuredAgentSessionHandoffTransport() - const recovered = await transport.recoverTuiOwner(record) - expect(recovered).toMatchObject({ - transcriptPath, - link: { - handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' }, - origin: 'resumed', - mintedAtFence: 3 - } - }) - expect(recovered.link.linkId).not.toBe('claude-old') - - const pty = internal.ptysById.get('pty-claude') as { - launchToken: string | null - } - pty.launchToken = null - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! - internal.restoredOrchestrationAuthorityByPtyId.set('pty-claude', { - ptyId: 'pty-claude', - worktreeId: WORKTREE_ID, - terminalHandle: recovered.terminal.handle, - paneKey: recovered.terminal.paneKey, - processIncarnation: dispatchAuthority.processIncarnation, - hostScope: dispatchAuthority.hostScope - }) - - expect( - runtime.verifyOrchestrationCompatibilityCaller({ - terminalHandle: recovered.terminal.handle, - paneKey, - launchToken: spawnToken - }) - ).toMatchObject({ - paneKey, - terminalHandle: recovered.terminal.handle, - processIncarnation: dispatchAuthority.processIncarnation - }) - expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ - paneKey, - launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), - connectionId: null, - terminalProvenance: 'restored' - }) - attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) - expect( - runtime.verifyOrchestrationCompatibilityCaller({ - terminalHandle: recovered.terminal.handle, - paneKey, - launchToken: spawnToken - }) - ).toBeNull() - expect(agentSessionPtyWriteGate.boundSessionId('pty-claude')).toBe('session-1') - agentSessionPtyWriteGate.unbindPty('pty-claude') - }) - - it('requires restored hook attestation after the runtime restarts', async () => { - const paneKey = 'tab-restored:leaf-restored' - const spawnToken = 'restored-token' - const sessionId = '019fd532-7c11-7a90-b6de-4e1a2c3d5f62' - const transcriptPath = '/tmp/claude-home/projects/worktree/restored.jsonl' - const attestAgentHookCompatibilityAuthority = vi.fn(() => ({ - paneKey, - source: 'hydrated_commitment' as const - })) - const runtime = new OrcaRuntimeService(null, undefined, { - attestAgentHookCompatibilityAuthority, - getAgentProviderSessionRowsForPane: () => [ - { - paneKey, - connectionId: null, - state: 'done', - prompt: '', - agentType: 'claude', - receivedAt: Date.now() + 1000, - stateStartedAt: 10, - providerSession: { key: 'session_id', id: sessionId, transcriptPath } - } - ] - }) - const internal = runtime as unknown as { - createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport - ptysById: Map - restoredOrchestrationAuthorityByPtyId: Map - } - internal.ptysById.set('pty-restored', { - ptyId: 'pty-restored', - worktreeId: WORKTREE_ID, - connectionId: null, - tabId: 'tab-restored', - paneKey, - launchToken: spawnToken, - launchAgent: 'claude', - connected: true - }) - resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/restored.jsonl') - readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-restored') - const record = { - sessionId: 'session-restored', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, - providerHandleChain: [ - { - linkId: 'claude-restored', - handle: { provider: 'claude', sessionId, leafUuid: 'leaf-restored' }, - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ], - lease: { - runtimeFence: 4, - ownerProcess: { - hostId: 'local', - pid: 4545, - processStartTimeMs: 30, - spawnToken - }, - provenHandleLinkId: null - } - } as never - - const recovered = await internal - .createStructuredAgentSessionHandoffTransport() - .recoverTuiOwner(record) - const restoredPty = internal.ptysById.get('pty-restored') as { - launchToken: string | null - } - restoredPty.launchToken = null - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! - internal.restoredOrchestrationAuthorityByPtyId.set('pty-restored', { - ptyId: 'pty-restored', - worktreeId: WORKTREE_ID, - terminalHandle: recovered.terminal.handle, - paneKey: recovered.terminal.paneKey, - processIncarnation: dispatchAuthority.processIncarnation, - hostScope: dispatchAuthority.hostScope - }) - - expect( - runtime.verifyOrchestrationCompatibilityCaller({ - terminalHandle: recovered.terminal.handle, - paneKey, - launchToken: spawnToken - }) - ).toMatchObject({ - paneKey, - terminalHandle: recovered.terminal.handle, - processIncarnation: dispatchAuthority.processIncarnation - }) - expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ - paneKey, - launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), - connectionId: null, - terminalProvenance: 'restored' - }) - attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) - await expect( - runtime.verifyOrchestrationCompatibilityCaller({ - terminalHandle: recovered.terminal.handle, - paneKey, - launchToken: spawnToken - }) - ).toBeNull() - agentSessionPtyWriteGate.unbindPty('pty-restored') - }) - - it('proves the published launch tab before returning its revealed renderer binding', async () => { - let explicitStatus: { - state: 'working' | 'done' - prompt: string - receivedAt: number - stateStartedAt: number - paneKey: string - terminalHandle: string - } | null = null - const revealTerminalSession = vi.fn( - (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => - Promise.resolve({ tabId: 'tab-renderer' }) - ) - const runtime = new OrcaRuntimeService( - { - getSettings: () => ({ - disabledTuiAgents: [], - agentCmdOverrides: {}, - agentDefaultArgs: { - codex: '-m gpt-5.6-sol -c model_reasoning_effort=high' - }, - agentDefaultEnv: {} - }) - } as never, - undefined, - { - getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : []) - } - ) - runtime.setNotifier(notifier(revealTerminalSession) as never) - const spawn = vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }) - runtime.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: async () => null - }) - - const internal = runtime as unknown as { - createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport - resolveTerminalWorkspaceLaunchScope(): Promise<{ - id: string - path: string - connectionId: null - repo: null - folderWorkspace: null - }> - markLocalWorkspaceTrustedForAgent(): void - waitForTerminal(): Promise - waitForAdoptedStructuredTuiProof(): Promise<{ transcriptPath?: string }> - waitForStructuredTuiPtyExit(): Promise - closeTerminal(handle: string): Promise - handles: Map< - string, - { - rendererGraphEpoch: number - tabId: string - leafId: string - } - > - graphStatus: 'ready' - } - internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ - id: WORKTREE_ID, - path: '/tmp/structured-handoff', - connectionId: null, - repo: null, - folderWorkspace: null - })) - internal.markLocalWorkspaceTrustedForAgent = vi.fn() - const waitForTerminal = vi.fn(async () => ({})) - internal.waitForTerminal = waitForTerminal - const waitForAdoptedStructuredTuiProof = vi.fn(async () => { - const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) - expect(snapshot.tabs).toContainEqual( - expect.objectContaining({ - type: 'terminal', - parentTabId: expect.any(String), - leafId: expect.any(String), - ptyId: 'pty-structured', - terminal: expect.any(String) - }) - ) - expect(revealTerminalSession).not.toHaveBeenCalled() - return { transcriptPath: '/tmp/rollout.jsonl' } - }) - internal.waitForAdoptedStructuredTuiProof = waitForAdoptedStructuredTuiProof - const waitForStructuredTuiPtyExit = vi.fn(async () => {}) - internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit - const closeTerminal = vi.fn(async () => undefined) - internal.closeTerminal = closeTerminal - readStructuredTuiProcessIdentity.mockResolvedValue({ - hostId: 'local', - pid: 4243, - processStartTimeMs: 10, - spawnToken: 'spawn-token' - }) - probeAgentSessionProcessIdentity.mockResolvedValue({ - outcome: 'identity-matched', - matchedOn: ['process-start-time'] - }) - - const transport = internal.createStructuredAgentSessionHandoffTransport() - const onSpawned = vi.fn(async () => {}) - const owner = await transport.launchTui({ - record: { - sessionId: 'session-1', - location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - launchArgs: ['--search'], - options: { model: 'gpt-5.6-terra', effort: 'medium' }, - providerHandleChain: [ - { handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 } - ] - } as never, - fence: 3, - spawnToken: 'spawn-token', - onSpawned - }) - - const reveal = revealTerminalSession.mock.calls[0]?.[1] as { - tabId: string - leafId: string - } - expect(owner.terminal).toMatchObject({ - tabId: 'tab-renderer', - paneKey: `${reveal.tabId}:${reveal.leafId}`, - ptyId: 'pty-structured' - }) - expect(waitForTerminal).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ condition: 'tui-idle' }) - ) - expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() - expect(onSpawned).toHaveBeenCalledWith( - expect.objectContaining({ - terminal: expect.objectContaining({ ptyId: 'pty-structured' }), - process: expect.objectContaining({ spawnToken: 'spawn-token' }) - }) - ) - expect(onSpawned.mock.invocationCallOrder[0]).toBeLessThan( - waitForTerminal.mock.invocationCallOrder[0]! - ) - expect(waitForAdoptedStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( - revealTerminalSession.mock.invocationCallOrder[0]! - ) - const launchCommand = spawn.mock.calls[0]?.[0]?.command - expect(launchCommand).toContain("'-m' 'gpt-5.6-terra'") - expect(launchCommand).toContain("'-c' 'model_reasoning_effort=medium'") - expect(launchCommand).toContain("'--search'") - expect(launchCommand).not.toContain('gpt-5.6-sol') - expect(launchCommand).not.toContain('model_reasoning_effort=high') - - Object.assign(internal.handles.get(owner.terminal.handle)!, { - rendererGraphEpoch: -1, - tabId: 'tab-retired', - leafId: 'leaf-retired' - }) - internal.graphStatus = 'ready' - - explicitStatus = { - state: 'working', - prompt: '', - receivedAt: Date.now(), - stateStartedAt: Date.now(), - paneKey: owner.terminal.paneKey, - terminalHandle: owner.terminal.handle - } - expect(transport.tuiStatus(owner)).toBe('busy') - await expect( - transport.waitForTuiIdleOrExit(owner, new AbortController().signal) - ).resolves.toBeNull() - - explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() } - expect(transport.tuiStatus(owner)).toBe('idle') - await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( - 'idle' - ) - - explicitStatus = null - const livePty = ( - runtime as unknown as { - ptysById: Map< - string, - { - tailBuffer: string[] - tailPartialLine: string - preview: string - lastAgentStatus: null - lastAgentStatusObservedLive: boolean - } - > - } - ).ptysById.get('pty-structured')! - Object.assign(livePty, { - tailBuffer: [ - 'OpenAI Codex (v0.147.0)', - 'model: gpt-5.6-terra', - 'directory: /tmp/structured-handoff' - ], - tailPartialLine: '', - preview: '', - lastAgentStatus: null, - lastAgentStatusObservedLive: false - }) - expect(transport.tuiStatus(owner)).toBe('idle') - await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( - 'idle' - ) - - const pty = ( - runtime as unknown as { - ptysById: Map - } - ).ptysById.get('pty-structured')! - pty.launchToken = null - const persistedRecord = { - sessionId: 'session-1', - providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], - lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId } - } as never - - const rebound = await transport.reproveTuiOwner({ record: persistedRecord, owner }) - expect(rebound.terminal).toMatchObject({ - ptyId: 'pty-structured', - tabId: owner.terminal.tabId, - paneKey: owner.terminal.paneKey - }) - expect(rebound.terminal.handle).not.toBe(owner.terminal.handle) - await transport.waitForTuiExit(rebound) - expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured') - expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() - - await expect(transport.closeTuiOwner?.(rebound)).resolves.toEqual({ - transcriptPath: '/tmp/rollout.jsonl' - }) - expect(closeTerminal).toHaveBeenCalledWith(rebound.terminal.handle) - - explicitStatus = null - pty.connected = false - await expect( - transport.waitForTuiIdleOrExit(rebound, new AbortController().signal) - ).resolves.toBe('exited') - await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined() - }) - - it('reveals Claude structured native sessions into the mobile graph', async () => { - const runtime = new OrcaRuntimeService() - const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') - const focusEditorTab = vi.fn() - runtime.setNotifier({ focusEditorTab } as never) - const internal = runtime as unknown as { - createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport - } - - await internal.createStructuredAgentSessionHandoffTransport().revealNativeSession?.({ - workspaceId: WORKTREE_ID, - sessionId: 'session-claude', - agent: 'claude' - }) - - expect(publish).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WORKTREE_ID, - sessionId: 'session-claude', - agent: 'claude', - activate: false - }) - ) - expect(focusEditorTab).toHaveBeenCalledWith( - 'structured-agent-session-session-claude', - WORKTREE_ID - ) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index afed92ec8aa4..cef9e7dd8c13 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -149,11 +149,6 @@ const CENSUS: readonly CensusRow[] = [ kind: 'consumes', role: 'prompt-submission serialization, matched on pane key' }, - { - path: 'main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts', - kind: 'consumes', - role: 'recovered transcript resolution from provider-session rows, matched on pane key' - }, { path: 'main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts', kind: 'consumes', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 0ee8bae1ae1d..72c52c888fd3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -73,10 +73,6 @@ export const ADMISSION_METHODS = [ method: 'agentSession.threadGoal', params: { envelope: envelope(), change: { kind: 'clear' } } }, - { - method: 'agentSession.requestHandoff', - params: { envelope: envelope(), direction: 'to-tui', mode: 'now' } - }, { method: 'agentSession.handoffStatus', params: { sessionId: SESSION } }, { method: 'agentSession.options', params: { sessionId: SESSION } }, { method: 'agentSession.modelCatalog', params: { agent: 'codex', sessionId: SESSION } }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 60b284250570..95e3e3d6eee7 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -48,7 +48,7 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo * sounds: * * - Starts, extends, retains or reads work -> `requireStructuredHost`. Revoked admission means - * no new turns, no new holds, no new reads. create, send, ensure, setOption, requestHandoff, + * no new turns, no new holds, no new reads. create, send, ensure, setOption, * subscribe, hold, reveal, history, options and the status stream all live here. * - Stops or retires work the caller already owns -> `requireStructuredCleanupHost`. close, * cancel, unsubscribe and release live here. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 9f698a447418..a9cf5cd79576 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -184,21 +184,6 @@ export function hostStub(): StructuredAgentSessionHost { respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), changeThreadGoal: vi.fn(async () => ({ ok: true, replayed: false })), - requestHandoff: vi.fn(async () => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-a', sequence: 0 }, - value: { - status: { - owner: 'native', - direction: null, - phase: 'idle', - stage: null, - operationId: null - } - } - })), supportsCreate: vi.fn(() => true), handoffStatus: vi.fn(async () => ({ owner: 'native' })), readOptions: vi.fn(async () => ({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 4425e4b883cb..9daf97d55af2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -9,7 +9,6 @@ export { CreateIntentParams, CreateParams, CreateSupportParams, - HandoffParams, HandoffStatusParams, HistoryParams, HoldParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index b817101d9687..2d2760832add 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -167,7 +167,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(30) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(29) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -696,21 +696,6 @@ describe('method routing', () => { expect(response).toMatchObject({ ok: true }) expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) }) - - it('routes the structured handoff mutation through the host', async () => { - const response = await call('agentSession.requestHandoff', { - envelope: envelope(), - direction: 'to-tui', - mode: 'now', - action: 'start' - }) - - expect(response).toMatchObject({ ok: true }) - expect(hostCalls.requestHandoff).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ direction: 'to-tui', mode: 'now', action: 'start' }) - ) - }) }) describe('parameter validation', () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index e2e37593172d..66d44d4b7820 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -53,7 +53,6 @@ import { CreateParams, CreateSupportParams, HistoryParams, - HandoffParams, HandoffStatusParams, OptionsParams, RespondParams, @@ -232,11 +231,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: SetOptionParams, handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) }), - defineMethod({ - name: 'agentSession.requestHandoff', - params: HandoffParams, - handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params) - }), defineMethod({ name: 'agentSession.handoffStatus', params: HandoffStatusParams, diff --git a/src/main/runtime/runtime-extraction-regressions.test.ts b/src/main/runtime/runtime-extraction-regressions.test.ts index 4f5b1ca4bee1..dfb2660024c1 100644 --- a/src/main/runtime/runtime-extraction-regressions.test.ts +++ b/src/main/runtime/runtime-extraction-regressions.test.ts @@ -59,20 +59,13 @@ describe('runtime extraction regressions', () => { expect(runtime.getStatus().hostPlatform).toBe(process.platform) }) - it('labels a handed-off terminal with the same name status publishes', () => { + it('reads a machine rename the same way status publishes it', () => { const settings = { machineName: 'Build server' } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test exercises status with only the store surface the runtime reads during construction. const runtime = new OrcaRuntimeService({ getSettings: () => settings } as RuntimeStore) - type TransportFactory = { - createStructuredAgentSessionHandoffTransport(): { hostLabel: string } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the transport factory is protected; the test reaches it to compare the label it hands the handoff status. - const factory: TransportFactory = runtime as unknown as TransportFactory - const transport = factory.createStructuredAgentSessionHandoffTransport() - expect(transport.hostLabel).toBe('Build server') + expect(runtime.readMachineName()).toBe('Build server') settings.machineName = 'Renamed desk' - expect(transport.hostLabel).toBe(runtime.getStatus().machineName) - expect(transport.hostLabel).toBe('Renamed desk') + expect(runtime.getStatus().machineName).toBe('Renamed desk') // The remote-workspace client identity reads this same accessor at send time. expect(runtime.readMachineName()).toBe('Renamed desk') }) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 12b1466cacf1..bb23548ae6f5 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -94,11 +94,6 @@ export type RuntimePtyController = { agentSessionEnsure?: AgentSessionClaimedSpawnResult }> write(ptyId: string, data: string): boolean - writeAgentSessionProof?( - ptyId: string, - data: string, - authority: { sessionId: string; spawnToken: string } - ): boolean /** Three-valued settlement; local providers settle synchronously. */ writeWithSettlement?(ptyId: string, data: string): WriteSettlement | Promise /** Attach-only adoption of a live local daemon session so its output streams diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 36e917ebc7d0..5ee2cc8e7fcd 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -77,7 +77,6 @@ export type TerminalCreateOptions = { persistHostSessionBinding?: boolean agentSessionClaim?: AgentSessionExecutionClaim agentSessionCreateOperationId?: string - structuredAgentSessionId?: string signal?: AbortSignal onPtySpawnCommitted?: () => void deferMobileSessionPublish?: boolean diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 8bdac82abbee..2bc7337fe3fd 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -56,7 +56,7 @@ export function resolveCommittedStructuredAgentSessionAdoptionIntent(input: { accountHome: replay.record.accountHome, ...(input.options ? { options: input.options } : {}), adopt: { providerHandle: replay.providerHandle }, - runtimeKind: replay.record.lease.runtimeKind + runtimeKind: 'native' } } diff --git a/src/main/runtime/structured-agent-session-pty-binding.test.ts b/src/main/runtime/structured-agent-session-pty-binding.test.ts deleted file mode 100644 index e6656c025a8b..000000000000 --- a/src/main/runtime/structured-agent-session-pty-binding.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' -import { OrcaRuntimeService } from './orca-runtime' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const SESSION_ID = 'structured-session-1' - -afterEach(() => agentSessionPtyWriteGate.detachRecordLookup()) - -describe('structured handoff PTY binding', () => { - it('binds before runtime writes and unbinds on process exit', async () => { - const runtime = new OrcaRuntimeService() - const internal = runtime as unknown as { - resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{ - id: string - path: string - connectionId: null - repo: null - folderWorkspace: null - }> - } - vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ - id: 'worktree-1', - path: '/tmp/worktree-1', - connectionId: null, - repo: null, - folderWorkspace: null - }) - const write = vi.fn(() => true) - runtime.setPtyController({ - spawn: vi.fn(async () => ({ id: 'pty-structured', pid: 4200 })), - write, - kill: () => true, - getForegroundProcess: async () => null - }) - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ - sessionId: SESSION_ID, - runtimeKind: 'native', - handoffStage: 'new-owner-proving' - }) - ) - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => - sessionId === SESSION_ID ? record : null - ) - - await runtime.createTerminal('id:worktree-1', { - command: 'codex resume thread-1', - structuredAgentSessionId: SESSION_ID - }) - - expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBe(SESSION_ID) - await expect(runtime.writeTerminalPreviewInput('pty-structured', 'unsafe')).resolves.toBe(false) - expect(write).not.toHaveBeenCalled() - - runtime.onPtyExit('pty-structured', 0) - expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBeNull() - }) -}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 9656e7085f15..aad5bcf62579 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -31,7 +31,6 @@ import { type StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' -import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { readClaudeManagedAccountGateSettings, @@ -47,7 +46,6 @@ import { import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' -import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' @@ -107,7 +105,6 @@ export type StructuredAgentSessionRuntimeDeps = { onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] /** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */ statusSink?: StructuredAgentSessionHostDeps['statusSink'] - handoffTransport?: StructuredAgentSessionHandoffTransport reapOrphanChildren?: typeof stopOrphanAgentSessionChildren /** The account home a structured launch would pin right now, for catalog * reads with no session record. Absent disables the catalog surface. */ @@ -321,12 +318,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { - await store.transitionHandoff(sessionId, (record) => - recordAgentSessionProviderHandle({ record, fence: record.lease.runtimeFence, link, now }) - ) - }, - ...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {}), ...(await modelCatalogHostDeps({ store, deps, envResolvers })) }) setStructuredAgentSessionHost(host) diff --git a/src/main/runtime/structured-tui-exit-proof.test.ts b/src/main/runtime/structured-tui-exit-proof.test.ts deleted file mode 100644 index 3652aae18800..000000000000 --- a/src/main/runtime/structured-tui-exit-proof.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' -import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' - -const identity: AgentSessionProcessIdentity = { - hostId: 'local', - pid: 123, - processStartTimeMs: 1_700_000_000_000, - spawnToken: 'spawn-token' -} - -describe('structured TUI exit proof', () => { - it('accepts the exact terminal exit event without probing', async () => { - const probe = vi.fn() - - await expect( - waitForStructuredTuiExitProof({ identity, waitForExit: async () => {}, probe }) - ).resolves.toBeUndefined() - expect(probe).not.toHaveBeenCalled() - }) - - it.each([ - { outcome: 'pid-absent' as const }, - { outcome: 'identity-mismatch' as const, field: 'process-start-time' as const } - ])('accepts a retired handle only when the recorded process is gone: $outcome', async (proof) => { - await expect( - waitForStructuredTuiExitProof({ - identity, - waitForExit: async () => { - throw new Error('terminal_handle_stale') - }, - probe: async () => proof - }) - ).resolves.toBeUndefined() - }) - - it.each([ - { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] }, - { outcome: 'indeterminate' as const, reason: 'probe unavailable' } - ])('fails closed while the recorded process may still own: $outcome', async (proof) => { - await expect( - waitForStructuredTuiExitProof({ - identity, - waitForExit: async () => { - throw new Error('terminal_handle_stale') - }, - probe: async () => proof, - staleHandleProbeAttempts: 1 - }) - ).rejects.toThrow('terminal_handle_stale') - }) - - it('retries the persisted process identity when handle retirement wins the exit race', async () => { - const probe = vi - .fn() - .mockResolvedValueOnce({ - outcome: 'identity-matched' as const, - matchedOn: ['process-start-time' as const] - }) - .mockResolvedValueOnce({ outcome: 'pid-absent' as const }) - - await expect( - waitForStructuredTuiExitProof({ - identity, - waitForExit: async () => { - throw new Error('terminal_handle_stale') - }, - probe, - staleHandleProbeAttempts: 2, - staleHandleProbeIntervalMs: 0 - }) - ).resolves.toBeUndefined() - expect(probe).toHaveBeenNthCalledWith(1, identity) - expect(probe).toHaveBeenNthCalledWith(2, identity) - }) - - it('preserves unrelated terminal wait failures', async () => { - const probe = vi.fn() - - await expect( - waitForStructuredTuiExitProof({ - identity, - waitForExit: async () => { - throw new Error('timeout') - }, - probe - }) - ).rejects.toThrow('timeout') - expect(probe).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/structured-tui-exit-proof.ts b/src/main/runtime/structured-tui-exit-proof.ts deleted file mode 100644 index c9c733f4ef27..000000000000 --- a/src/main/runtime/structured-tui-exit-proof.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' -import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' - -type ExitProofInput = { - identity: AgentSessionProcessIdentity - waitForExit: () => Promise - probe?: (identity: AgentSessionProcessIdentity) => Promise - staleHandleProbeAttempts?: number - staleHandleProbeIntervalMs?: number -} - -const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50 -const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100 - -function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean { - return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch' -} - -async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise { - const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) - const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS - const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS - for (let attempt = 0; attempt < attempts; attempt += 1) { - if (provesRecordedProcessExited(await probe(input.identity))) { - return - } - if (attempt + 1 < attempts) { - await new Promise((resolve) => setTimeout(resolve, intervalMs)) - } - } - throw staleError -} - -export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise { - try { - await input.waitForExit() - } catch (error) { - if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') { - throw error - } - await waitForRecordedProcessExit(input, error) - } -} diff --git a/src/main/runtime/structured-tui-idle-evidence.test.ts b/src/main/runtime/structured-tui-idle-evidence.test.ts deleted file mode 100644 index 7177cb9bfc3f..000000000000 --- a/src/main/runtime/structured-tui-idle-evidence.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' - -describe('structured TUI idle evidence', () => { - it('does not treat a ready prompt preview as proof that a turn is idle', () => { - const readyPreview = ' >_ OpenAI Codex\n model: gpt-5.5\n directory: /workspace' - expect(readyPreview).toContain('OpenAI Codex') - expect( - hasStructuredTuiIdleEvidence({ blocked: false, status: null, statusObservedLive: false }) - ).toBe(false) - }) - - it('requires an explicit idle state and still rejects blocked prompts', () => { - expect( - hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: true }) - ).toBe(true) - expect( - hasStructuredTuiIdleEvidence({ blocked: true, status: 'idle', statusObservedLive: true }) - ).toBe(false) - }) - - it('does not authorize a restored idle status before live observation', () => { - expect( - hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: false }) - ).toBe(false) - }) -}) diff --git a/src/main/runtime/structured-tui-idle-evidence.ts b/src/main/runtime/structured-tui-idle-evidence.ts deleted file mode 100644 index d91518cdd4c4..000000000000 --- a/src/main/runtime/structured-tui-idle-evidence.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AgentStatus } from '../../shared/agent-title-core' - -export function hasStructuredTuiIdleEvidence(input: { - blocked: boolean - status: AgentStatus | null - statusObservedLive: boolean -}): boolean { - return !input.blocked && input.status === 'idle' && input.statusObservedLive -} diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts deleted file mode 100644 index fb5919824e54..000000000000 --- a/src/main/runtime/structured-tui-process-identity.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - readStructuredTuiProcessIdentity, - resolveStructuredTuiChildPid -} from './structured-tui-process-identity' - -describe('structured TUI process identity', () => { - it('keeps the first row when a process snapshot contains duplicate PIDs', () => { - const child = { - pid: 101, - ppid: 100, - command: 'codex resume first', - foreground: true - } - const duplicate = { ...child, command: 'codex resume duplicate' } - - expect( - resolveStructuredTuiChildPid( - [{ pid: 100, ppid: 1, command: '/bin/zsh', foreground: false }, child, duplicate], - 100, - 'codex' - ) - ).toBe(101) - }) - - it('indexes process rows once instead of rescanning for every descendant', () => { - const rowCount = 64 - let pidReads = 0 - const rows = Array.from({ length: rowCount }, (_, index) => { - const pid = 100 + index - const row = { - pid, - ppid: index === 0 ? 1 : pid - 1, - command: index === 0 ? '/bin/zsh' : `codex resume ${index}`, - foreground: index > 0 - } - Object.defineProperty(row, 'pid', { - configurable: true, - get: () => { - pidReads += 1 - return pid - } - }) - return row - }) - - expect(resolveStructuredTuiChildPid(rows, 100, 'codex')).toBe(101) - // The old per-descendant Array#find path performed quadratic PID reads on this chain. - expect(pidReads).toBeLessThan(rowCount * 8) - }) - - it('binds the direct Codex child instead of the PTY shell pid', async () => { - const readStartTime = vi.fn(async () => 1_700_000_000_000) - await expect( - readStructuredTuiProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-1', - agent: 'codex', - platform: 'darwin', - readPosixRows: async () => [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - { - pid: 101, - ppid: 100, - stat: 'S+', - command: 'node /opt/codex/bin/codex resume abc' - }, - { - pid: 102, - ppid: 101, - stat: 'S+', - command: '/opt/codex/vendor/codex' - } - ], - readStartTime - }) - ).resolves.toEqual({ - hostId: 'local', - pid: 101, - processStartTimeMs: 1_700_000_000_000, - spawnToken: 'spawn-1' - }) - expect(readStartTime).toHaveBeenCalledWith(101, 'darwin') - }) - - it('fails closed when sibling Codex children make the owner ambiguous', async () => { - await expect( - readStructuredTuiProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-1', - agent: 'codex', - platform: 'win32', - readWindowsRows: async () => [ - { - pid: 100, - ppid: 1, - name: 'pwsh.exe', - command: 'pwsh.exe', - executablePath: '' - }, - { - pid: 101, - ppid: 100, - name: 'codex.exe', - command: 'codex resume a', - executablePath: '' - }, - { - pid: 102, - ppid: 100, - name: 'codex.exe', - command: 'codex resume b', - executablePath: '' - } - ], - timeoutMs: 0 - }) - ).rejects.toThrow('one exact Codex child process') - }) - - it('waits for a shell-delivered Codex child before binding ownership', async () => { - let snapshots = 0 - const delays: number[] = [] - await expect( - readStructuredTuiProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-2', - agent: 'codex', - platform: 'darwin', - readPosixRows: async () => { - snapshots += 1 - return [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - ...(snapshots >= 3 - ? [ - { - pid: 101, - ppid: 100, - stat: 'S+', - command: 'codex resume session-1' - } - ] - : []) - ] - }, - readStartTime: async () => 1_700_000_000_000, - timeoutMs: 1_000, - pollIntervalMs: 25, - now: () => delays.length * 25, - sleep: async (delayMs) => { - delays.push(delayMs) - } - }) - ).resolves.toEqual({ - hostId: 'local', - pid: 101, - processStartTimeMs: 1_700_000_000_000, - spawnToken: 'spawn-2' - }) - expect(delays).toEqual([25, 25]) - }) - - // Each poll forks a whole-machine `ps` (~0.065 CPU-s at 1,460 processes), so the - // capture COUNT per identification is the cost, not the 5s wall ceiling. - function countCapturesForIdentification(input: { - captureCostMs: number - childAppearsAtMs: number | null - }): Promise<{ - captures: number - identifiedAtMs: number | null - elapsedMs: number - }> { - let clockMs = 0 - let captures = 0 - return readStructuredTuiProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-cost', - agent: 'codex', - platform: 'darwin', - readPosixRows: async () => { - captures += 1 - clockMs += input.captureCostMs - return [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - ...(input.childAppearsAtMs !== null && clockMs >= input.childAppearsAtMs - ? [ - { - pid: 101, - ppid: 100, - stat: 'S+', - command: 'codex resume session-1' - } - ] - : []) - ] - }, - readStartTime: async () => 1_700_000_000_000, - now: () => clockMs, - sleep: async (delayMs) => { - clockMs += delayMs - } - }).then( - () => ({ captures, identifiedAtMs: clockMs, elapsedMs: clockMs }), - () => ({ captures, identifiedAtMs: null, elapsedMs: clockMs }) - ) - } - - it('does not spend a hundred ps captures on an identification that never resolves', async () => { - const { captures, identifiedAtMs, elapsedMs } = await countCapturesForIdentification({ - captureCostMs: 55, - childAppearsAtMs: null - }) - - expect(identifiedAtMs).toBeNull() - // The 5s ceiling is unchanged; only the captures inside it are. - expect(elapsedMs).toBeGreaterThanOrEqual(5_000) - // A flat 50ms poll spends ~48 captures here. - expect(captures).toBeLessThanOrEqual(20) - }) - - it('keeps identification latency identical while a child can still plausibly appear', async () => { - // The backoff must not touch the window a real spawn lands in: same capture - // count and same detection time as the flat 50ms poll. - for (const childAppearsAtMs of [0, 200, 500, 900]) { - const flatPollCaptures = Math.max(1, Math.ceil(childAppearsAtMs / (55 + 50)) + 1) - const { captures, identifiedAtMs } = await countCapturesForIdentification({ - captureCostMs: 55, - childAppearsAtMs - }) - - expect(identifiedAtMs).not.toBeNull() - expect(captures).toBeLessThanOrEqual(flatPollCaptures) - expect(identifiedAtMs!).toBeLessThanOrEqual(childAppearsAtMs + 55 + 50) - } - }) - - it('does not call a child absent after a single look that outlasted the budget', async () => { - // Measured on a 2,085-process host under load: one whole-machine `ps` took 6.2s while the - // shell-delivered child landed at ~3.5s. `ps` reads the table when it STARTS, so that one - // capture reported a t=0 machine and returned with the 5s budget already spent -- the loop - // answered "no exact child" without ever looking again. - let clockMs = 0 - let captures = 0 - await expect( - readStructuredTuiProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-slow-ps', - agent: 'claude', - platform: 'darwin', - readPosixRows: async () => { - captures += 1 - const observedAtMs = clockMs - clockMs += 6_200 - return [ - { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - ...(observedAtMs >= 3_500 - ? [{ pid: 101, ppid: 100, stat: 'S+', command: 'claude --resume session-1' }] - : []) - ] - }, - readStartTime: async () => 1_700_000_000_000, - now: () => clockMs, - sleep: async (delayMs) => { - clockMs += delayMs - } - }) - ).resolves.toMatchObject({ pid: 101, spawnToken: 'spawn-slow-ps' }) - expect(captures).toBe(2) - }) - - it('fails closed when the process snapshot omitted the PTY root', async () => { - await expect( - readStructuredTuiProcessIdentity({ - hostId: 'local', - rootPid: 100, - spawnToken: 'spawn-1', - agent: 'codex', - platform: 'darwin', - readPosixRows: async () => [ - { pid: 101, ppid: 100, stat: 'S+', command: 'codex resume abc' } - ] - }) - ).rejects.toThrow('root process was not present') - }) -}) diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts deleted file mode 100644 index f5ee019882b0..000000000000 --- a/src/main/runtime/structured-tui-process-identity.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { getFreshProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' -import type { ProcessTableRow } from '../../shared/process-table-snapshot' -import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' -import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' -import { queryWindowsProcessRowsFresh } from '../providers/windows-foreground-process-rows' -import { - PROCESS_START_TIME_TOLERANCE_MS, - readProcessStartTimeMs -} from './agent-session-process-identity-probe' - -type ProcessRow = { pid: number; ppid: number; command: string; foreground: boolean } - -const STRUCTURED_TUI_PROCESS_WAIT_MS = 5_000 -const STRUCTURED_TUI_PROCESS_POLL_MS = 50 -// Why: every poll forks a whole-machine `ps` (~0.065 CPU-s at 1,460 processes), -// and the 5s ceiling is only reached when the child never appears at all — so the -// tight interval buys nothing there. Hold it for the window in which a spawning -// child plausibly lands (detection latency byte-identical), then widen. Past the -// window the added latency is bounded by one interval. -const STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS = 1_000 -const STRUCTURED_TUI_PROCESS_MAX_POLL_MS = 500 -// Why a floor and not just the deadline: the first capture races the spawn it is looking for, -// so a null from it is absence of the child's arrival, not evidence the child is missing. The -// budget above assumes a look is nearly free, but one whole-machine `ps` measured 6.2s on a -// 2,085-process host under load -- long enough to spend the entire budget before the child -// (observed landing at ~3.5s) could exist, and answer "no exact child" after a single look. -const STRUCTURED_TUI_PROCESS_MIN_CAPTURES = 2 - -function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { - const children = new Map() - const rowByPid = new Map() - for (const row of rows) { - const bucket = children.get(row.ppid) - if (bucket) { - bucket.push(row) - } else { - children.set(row.ppid, [row]) - } - // Array#find below was first-match-wins for duplicate PIDs; retain that contract in the index. - if (!rowByPid.has(row.pid)) { - rowByPid.set(row.pid, row) - } - } - const found: (ProcessRow & { depth: number })[] = [] - const pending = [{ pid: rootPid, depth: 0 }] - const seen = new Set() - while (pending.length > 0) { - const current = pending.pop()! - if (seen.has(current.pid)) { - continue - } - seen.add(current.pid) - const row = rowByPid.get(current.pid) - if (row) { - found.push({ ...row, depth: current.depth }) - } - for (const child of children.get(current.pid) ?? []) { - pending.push({ pid: child.pid, depth: current.depth + 1 }) - } - } - return found -} - -function excludedProcessTreePids( - rows: ProcessRow[], - rootPids: ReadonlySet | undefined -): ReadonlySet { - if (!rootPids || rootPids.size === 0) { - return new Set() - } - const excluded = new Set(rootPids) - const children = new Map() - for (const row of rows) { - const bucket = children.get(row.ppid) - if (bucket) { - bucket.push(row.pid) - } else { - children.set(row.ppid, [row.pid]) - } - } - const pending = [...rootPids] - while (pending.length > 0) { - for (const childPid of children.get(pending.pop()!) ?? []) { - if (!excluded.has(childPid)) { - excluded.add(childPid) - pending.push(childPid) - } - } - } - return excluded -} - -async function resolveExcludedProcessTreePids( - rows: ProcessRow[], - identities: readonly { pid: number; processStartTimeMs: number | null }[] | undefined, - platform: NodeJS.Platform, - readStartTime: (pid: number, platform?: NodeJS.Platform) => Promise -): Promise> { - if (!identities || identities.length === 0) { - return new Set() - } - const roots = new Set() - const pids = new Set() - for (const row of rows) { - pids.add(row.pid) - } - for (const identity of identities) { - if (!pids.has(identity.pid)) { - continue - } - // Unavailable start time cannot prove PID reuse, so retain the conservative exclusion. - if (identity.processStartTimeMs === null) { - roots.add(identity.pid) - continue - } - const observed = await readStartTime(identity.pid, platform) - if ( - observed === null || - Math.abs(observed - identity.processStartTimeMs) <= PROCESS_START_TIME_TOLERANCE_MS - ) { - roots.add(identity.pid) - } - } - return excludedProcessTreePids(rows, roots) -} - -export function resolveStructuredTuiChildPid( - rows: ProcessRow[], - rootPid: number, - agent: AgentSessionHandleProvider, - processCommandMatches?: (command: string) => boolean, - excludedPids?: ReadonlySet -): number | null { - const candidates = descendants(rows, rootPid).filter( - (row) => - !excludedPids?.has(row.pid) && - recognizeAgentProcessFromCommandLine(row.command)?.agent === agent && - (processCommandMatches?.(row.command) ?? true) - ) - const foreground = candidates.filter((row) => row.foreground) - const eligible = foreground.length > 0 ? foreground : candidates - eligible.sort((left, right) => left.depth - right.depth || left.pid - right.pid) - if (eligible.length === 0 || eligible[1]?.depth === eligible[0]?.depth) { - return null - } - return eligible[0]!.pid -} - -function posixRows(rows: ProcessTableRow[]): ProcessRow[] { - return rows.map((row) => ({ - pid: row.pid, - ppid: row.ppid, - command: row.command, - foreground: row.stat.includes('+') - })) -} - -export async function readStructuredTuiProcessIdentity(input: { - hostId: string - rootPid: number - spawnToken: string - agent: AgentSessionHandleProvider - platform?: NodeJS.Platform - readPosixRows?: () => Promise - readWindowsRows?: typeof queryWindowsProcessRowsFresh - readStartTime?: (pid: number, platform?: NodeJS.Platform) => Promise - timeoutMs?: number - pollIntervalMs?: number - now?: () => number - sleep?: (delayMs: number) => Promise - processCommandMatches?: (command: string) => boolean - excludedProcessTreeRootIdentities?: readonly { - pid: number - processStartTimeMs: number | null - }[] -}): Promise { - const platform = input.platform ?? process.platform - const now = input.now ?? Date.now - const sleep = input.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) - const startedAtMs = now() - const deadline = startedAtMs + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) - let pollDelayMs = input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS - let captures = 0 - - while (true) { - const rows: ProcessRow[] = - platform === 'win32' - ? (await (input.readWindowsRows ?? queryWindowsProcessRowsFresh)()).map((row) => ({ - pid: row.pid, - ppid: row.ppid, - command: row.command, - foreground: false - })) - : posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)()) - captures += 1 - let rootPresent = false - for (const row of rows) { - if (row.pid === input.rootPid) { - rootPresent = true - break - } - } - if (!rootPresent) { - throw new Error('The terminal root process was not present in the process snapshot.') - } - const excludedPids = await resolveExcludedProcessTreePids( - rows, - input.excludedProcessTreeRootIdentities, - platform, - input.readStartTime ?? readProcessStartTimeMs - ) - const pid = resolveStructuredTuiChildPid( - rows, - input.rootPid, - input.agent, - input.processCommandMatches, - excludedPids - ) - if (pid !== null) { - return { - hostId: input.hostId, - pid, - processStartTimeMs: await (input.readStartTime ?? readProcessStartTimeMs)(pid, platform), - spawnToken: input.spawnToken - } - } - const remainingMs = deadline - now() - if (remainingMs <= 0 && captures >= STRUCTURED_TUI_PROCESS_MIN_CAPTURES) { - const label = input.agent === 'codex' ? 'Codex' : 'Claude' - throw new Error(`The resumed terminal did not expose one exact ${label} child process.`) - } - await sleep(Math.max(0, Math.min(pollDelayMs, remainingMs))) - if (now() - startedAtMs >= STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS) { - // Never below the caller's interval, so an explicitly slow poll stays slow. - pollDelayMs = Math.max( - pollDelayMs, - Math.min(pollDelayMs * 2, STRUCTURED_TUI_PROCESS_MAX_POLL_MS) - ) - } - } -} diff --git a/src/main/runtime/structured-tui-recovery-claim-match.test.ts b/src/main/runtime/structured-tui-recovery-claim-match.test.ts deleted file mode 100644 index 39495872b936..000000000000 --- a/src/main/runtime/structured-tui-recovery-claim-match.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - evaluateStructuredTuiRecoveryClaim, - type StructuredTuiRecoveryClaimCandidate -} from './structured-tui-recovery-claim-match' - -// Modeled after a packaged restart whose first recovery claim failed. -const PACKAGED_CANDIDATE: StructuredTuiRecoveryClaimCandidate = { - expectedWorkspaceId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', - claimMatches: true, - pty: { - connected: true, - ptyId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', - incarnationId: '4cf23679-8987-487d-a24c-dba3bed1b442', - worktreeId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture' - }, - owner: { - phase: 'live', - ptyId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', - surface: { - worktreeId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', - tabId: 'ced3bd39-262b-41f3-a446-92ceab4f938c', - leafId: 'd4e9d94d-8ec3-4d0d-8ca3-52730ba61c24' - } - }, - persisted: { - sessionResolved: true, - tabPresent: true, - ptyId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', - incarnationId: null - } -} - -type CandidatePatch = Partial< - Omit -> & { - owner?: Partial> & { - surface?: Partial - } - persisted?: Partial - pty?: Partial -} - -const MISMATCH_CASES: [string, CandidatePatch, string[]][] = [ - ['connection', { pty: { connected: false } }, ['connected']], - ['owner phase', { owner: { phase: 'retiring' } }, ['owner-phase']], - ['owner PTY', { owner: { ptyId: 'different-pty' } }, ['owner-pty-id', 'persisted-pty-id']], - ['presented incarnation', { pty: { incarnationId: null } }, ['presented-incarnation']], - ['PTY workspace', { pty: { worktreeId: 'different-workspace' } }, ['pty-workspace']], - [ - 'surface workspace', - { owner: { surface: { worktreeId: 'different-workspace' } } }, - ['surface-workspace'] - ], - ['claim', { claimMatches: false }, ['claim']], - ['persisted session', { persisted: { sessionResolved: false } }, ['persisted-session']], - ['persisted tab', { persisted: { tabPresent: false } }, ['persisted-tab']], - ['persisted PTY', { persisted: { ptyId: 'different-pty' } }, ['persisted-pty-id']], - [ - 'persisted incarnation', - { persisted: { incarnationId: 'different-incarnation' } }, - ['persisted-incarnation'] - ] -] - -describe('structured TUI packaged recovery claim matching', () => { - it('accepts the real first-claim surface while persisted incarnation hydration is pending', () => { - expect(evaluateStructuredTuiRecoveryClaim(PACKAGED_CANDIDATE)).toEqual({ - matches: true, - mismatchedFields: [] - }) - }) - - it('accepts the same surface once the persisted incarnation arrives', () => { - expect( - evaluateStructuredTuiRecoveryClaim({ - ...PACKAGED_CANDIDATE, - persisted: { - ...PACKAGED_CANDIDATE.persisted, - incarnationId: PACKAGED_CANDIDATE.pty.incarnationId - } - }) - ).toMatchObject({ matches: true }) - }) - - it.each(MISMATCH_CASES)('rejects a %s mismatch', (_label, patch, mismatchedFields) => { - const candidate = { - ...PACKAGED_CANDIDATE, - ...patch, - pty: { ...PACKAGED_CANDIDATE.pty, ...patch.pty }, - owner: { - ...PACKAGED_CANDIDATE.owner, - ...patch.owner, - surface: { ...PACKAGED_CANDIDATE.owner.surface, ...patch.owner?.surface } - }, - persisted: { ...PACKAGED_CANDIDATE.persisted, ...patch.persisted } - } - - expect(evaluateStructuredTuiRecoveryClaim(candidate)).toEqual({ - matches: false, - mismatchedFields - }) - }) -}) diff --git a/src/main/runtime/structured-tui-recovery-claim-match.ts b/src/main/runtime/structured-tui-recovery-claim-match.ts deleted file mode 100644 index 658ae15247c1..000000000000 --- a/src/main/runtime/structured-tui-recovery-claim-match.ts +++ /dev/null @@ -1,89 +0,0 @@ -export type StructuredTuiRecoveryClaimMismatch = - | 'claim' - | 'connected' - | 'owner-phase' - | 'owner-pty-id' - | 'persisted-incarnation' - | 'persisted-pty-id' - | 'persisted-session' - | 'persisted-tab' - | 'presented-incarnation' - | 'pty-workspace' - | 'surface-workspace' - -export type StructuredTuiRecoveryClaimCandidate = { - expectedWorkspaceId: string - claimMatches: boolean - pty: { - connected: boolean - ptyId: string - incarnationId: string | null - worktreeId: string - } - owner: { - phase: string - ptyId: string - surface: { - worktreeId: string - tabId: string - leafId: string - } - } - persisted: { - sessionResolved: boolean - tabPresent: boolean - ptyId: string | null - incarnationId: string | null - } -} - -export type StructuredTuiRecoveryClaimEvaluation = { - matches: boolean - mismatchedFields: StructuredTuiRecoveryClaimMismatch[] -} - -export function evaluateStructuredTuiRecoveryClaim( - candidate: StructuredTuiRecoveryClaimCandidate, - worktreeIdsEqual: (left: string, right: string) => boolean = (left, right) => left === right -): StructuredTuiRecoveryClaimEvaluation { - const mismatchedFields: StructuredTuiRecoveryClaimMismatch[] = [] - if (!candidate.pty.connected) { - mismatchedFields.push('connected') - } - if (candidate.owner.phase !== 'live') { - mismatchedFields.push('owner-phase') - } - if (candidate.owner.ptyId !== candidate.pty.ptyId) { - mismatchedFields.push('owner-pty-id') - } - if (!candidate.pty.incarnationId) { - mismatchedFields.push('presented-incarnation') - } - if (!worktreeIdsEqual(candidate.pty.worktreeId, candidate.expectedWorkspaceId)) { - mismatchedFields.push('pty-workspace') - } - if (!worktreeIdsEqual(candidate.owner.surface.worktreeId, candidate.expectedWorkspaceId)) { - mismatchedFields.push('surface-workspace') - } - if (!candidate.claimMatches) { - mismatchedFields.push('claim') - } - if (!candidate.persisted.sessionResolved) { - mismatchedFields.push('persisted-session') - } else { - if (!candidate.persisted.tabPresent) { - mismatchedFields.push('persisted-tab') - } - if (candidate.persisted.ptyId !== candidate.owner.ptyId) { - mismatchedFields.push('persisted-pty-id') - } - // Packaged hydration can omit this binding briefly; daemon incarnation and child proof stay mandatory. - if ( - candidate.persisted.incarnationId !== null && - candidate.persisted.incarnationId !== candidate.pty.incarnationId - ) { - mismatchedFields.push('persisted-incarnation') - } - } - return { matches: mismatchedFields.length === 0, mismatchedFields } -} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx deleted file mode 100644 index 4a185a3d630d..000000000000 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire' -import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome' - -const IDLE_NATIVE: AgentSessionHandoffStatus = { - owner: 'native', - direction: null, - phase: 'idle', - stage: null, - operationId: null -} - -afterEach(cleanup) - -describe('StructuredAgentSessionHandoffChrome', () => { - it('uses queued-safe admission when the native view still appears idle', () => { - const onRequest = vi.fn() - render( - - ) - - fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' })) - - expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn') - }) - - it('offers one Retry action for a recoverable dead TUI owner', () => { - const onRequest = vi.fn() - render( - - ) - - expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - expect(onRequest).toHaveBeenCalledWith('to-native', 'now', 'retry') - }) -}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx deleted file mode 100644 index 840039564d81..000000000000 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import type { - AgentSessionHandoffDirection, - AgentSessionHandoffMode, - AgentSessionHandoffStatus -} from '../../../../shared/agent-session-wire' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { translate } from '@/i18n/i18n' - -type Props = { - status: AgentSessionHandoffStatus | null - isWorking: boolean - onRequest: ( - direction: AgentSessionHandoffDirection, - mode: AgentSessionHandoffMode, - action?: 'start' | 'cancel-queued' | 'retry' | 'recover' - ) => void -} - -function handoffStageCopy(status: AgentSessionHandoffStatus): string { - if (status.stage === 'preparing') { - return status.direction === 'to-tui' - ? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…') - : translate( - 'components.native-chat.handoff.stage.finishingTerminal', - 'Finishing agent terminal…' - ) - } - if (status.stage === 'old-owner-stopped') { - return status.direction === 'to-tui' - ? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…') - : translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…') - } - if (status.stage === 'new-owner-proving') { - return status.direction === 'to-tui' - ? translate( - 'components.native-chat.handoff.stage.verifyingTerminal', - 'Verifying agent terminal…' - ) - : translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…') - } - if (status.stage === 'recovering') { - return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…') - } - if (status.stage === 'manual-recovery') { - return translate( - 'components.native-chat.handoff.stage.manualRecovery', - 'Agent session needs recovery' - ) - } - return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…') -} - -export function StructuredAgentSessionHandoffChrome({ - status, - isWorking, - onRequest -}: Props): React.JSX.Element | null { - if (!status) { - return null - } - const owner = status?.owner ?? 'native' - const phase = status?.phase ?? 'idle' - const switching = phase === 'switching' || phase === 'waiting-for-exit' - return ( - <> -
- - {switching - ? translate('components.native-chat.handoff.mode.switching', 'Switching') - : owner === 'tui' - ? translate('components.native-chat.handoff.mode.terminal', 'Terminal') - : translate('components.native-chat.handoff.mode.chat', 'Chat')} - -
- {phase === 'queued' && status?.direction ? ( - <> - - {status.direction === 'to-tui' - ? translate( - 'components.native-chat.handoff.switchingAfterTurn', - 'Switching after this turn' - ) - : translate( - 'components.native-chat.handoff.returningAfterTurn', - 'Returning after this turn' - )} - - - - ) : owner === 'native' && phase === 'idle' ? ( - isWorking ? ( - <> - - - - ) : ( - - ) - ) : owner === 'tui' && phase === 'idle' ? ( - - ) : null} -
-
- {owner === 'tui' && phase === 'idle' ? ( -
- - {status?.hostLabel - ? translate( - 'components.native-chat.handoff.agentOpenOnHost', - 'Agent is open in terminal on {{value0}}.', - { value0: status.hostLabel } - ) - : translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')} - - -
- ) : null} - {switching ? ( -
- {phase === 'waiting-for-exit' - ? translate( - 'components.native-chat.handoff.exitTerminal', - 'Exit the agent terminal to continue in chat.' - ) - : status?.stage - ? handoffStageCopy(status) - : translate( - 'components.native-chat.handoff.switchingOwner', - 'Switching session owner…' - )} -
- ) : null} - {phase === 'failed' && status?.error ? ( -
-
- {status.error.message} - {status.direction && status.error.canRetryProof ? ( - - ) : status.direction && status.error.recoverableOwner !== 'none' ? ( - - ) : null} -
- {status.error.details ? ( -
- {translate('components.native-chat.handoff.details', 'Details')} -

{status.error.details}

-
- ) : null} -
- ) : null} - - ) -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 64eb129d0913..304168a328ad 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -75,7 +75,6 @@ function sessionState(fence: number | null): StructuredAgentSessionState { retainedItemLimit: 1_024, hasOlder: false, status: 'ready', - handoff: null, commands: [] } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 84bd050d09b9..d7706913d21d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -80,10 +80,7 @@ export function useStructuredAgentSessionMutate(args: { return null } if (!result.ok) { - if ( - agentSessionRefusalOperationState(fingerprintMethod, result.refusal.code) === - 'settled-rejected' - ) { + if (agentSessionRefusalOperationState(result.refusal.code) === 'settled-rejected') { operationIds.current.delete(key) } if (enabledRef.current && stateRef.current.fence === targetFence) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index b8aad8061cc5..70778381c6b1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -91,7 +91,6 @@ function sessionState(): StructuredAgentSessionState { hasOlder: true, status: 'error', error: 'cached transport error', - handoff: null, commands: [{ name: 'provider-command', kind: 'command' }] } } diff --git a/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx b/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx deleted file mode 100644 index bba08bd914c4..000000000000 --- a/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Button } from '@/components/ui/button' -import { translate } from '@/i18n/i18n' - -export function StructuredAgentSessionTerminalReturnButton(props: { - enabled: boolean - onReturn?: () => void -}): React.JSX.Element | null { - if (!props.enabled) { - return null - } - return ( - - ) -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 683d82c35781..907d011e6571 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17672,38 +17672,6 @@ }, "launchPromptNotDelivered": "Not delivered — check the terminal", "structuredSessionLaunchFailed": "Could not open {{value0}} chat", - "handoff": { - "stage": { - "finishingChat": "Finishing chat session…", - "finishingTerminal": "Finishing agent terminal…", - "openingTerminal": "Opening agent terminal…", - "resumingChat": "Resuming chat session…", - "verifyingTerminal": "Verifying agent terminal…", - "verifyingChat": "Verifying chat session…", - "recovering": "Recovering agent session…", - "manualRecovery": "Agent session needs recovery" - }, - "switchingOwner": "Switching session owner…", - "mode": { - "switching": "Switching", - "terminal": "Terminal", - "chat": "Chat" - }, - "switchingAfterTurn": "Switching after this turn", - "returningAfterTurn": "Returning after this turn", - "cancel": "Cancel", - "switchAfterTurn": "Switch after this turn", - "stopTurnAndSwitch": "Stop turn and switch", - "openAgentTui": "Open agent TUI", - "returnAfterTurn": "Return after this turn", - "returnToChat": "Return to chat", - "agentOpenOnHost": "Agent is open in terminal on {{value0}}.", - "agentOpen": "Agent is open in terminal.", - "exitTerminal": "Exit the agent terminal to continue in chat.", - "retryProof": "Retry proof", - "retry": "Retry", - "details": "Details" - }, "structuredSessionLaunchFailedDescription": "Orca could not open a structured {{value0}} chat. See the logs for details.", "subagents": { "state": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0147111e3de6..64b98d6b1c9c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17600,38 +17600,6 @@ "questionCount": "{{value0}} questions" }, "structuredSessionLaunchFailed": "Impossible d'ouvrir le chat {{value0}}", - "handoff": { - "stage": { - "finishingChat": "Fin de la session de discussion…", - "finishingTerminal": "Terminal d'agent de finition…", - "openingTerminal": "Ouverture du terminal des agents…", - "resumingChat": "Reprise de la session de discussion…", - "verifyingTerminal": "Terminal de l'agent de vérification…", - "verifyingChat": "Vérification de la session de discussion…", - "recovering": "Récupération de la session de l'agent…", - "manualRecovery": "La session de l'agent doit être récupérée" - }, - "switchingOwner": "Changer de propriétaire de session…", - "mode": { - "switching": "Commutation", - "terminal": "Terminal", - "chat": "Chat" - }, - "switchingAfterTurn": "Changer après ce tour", - "returningAfterTurn": "Revenir après ce tour", - "cancel": "Annuler", - "switchAfterTurn": "Changer après ce tour", - "stopTurnAndSwitch": "Arrêtez de tourner et de changer", - "openAgentTui": "TUI à agent ouvert", - "returnAfterTurn": "Revenir après ce tour", - "returnToChat": "Revenir au chat", - "agentOpenOnHost": "L'agent est ouvert dans le terminal le {{value0}}.", - "agentOpen": "L'agent est ouvert dans le terminal.", - "exitTerminal": "Quittez le terminal de l'agent pour continuer le chat.", - "retryProof": "Réessayez la preuve", - "retry": "Réessayer", - "details": "Détails" - }, "structuredSessionLaunchFailedDescription": "Orca n'a pas pu ouvrir une discussion structurée {{value0}}. Consultez les journaux pour plus de détails.", "subagents": { "state": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index dae80d521e49..5b8e758ca3c0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17536,38 +17536,6 @@ "questionCount": "{{value0}} の質問" }, "structuredSessionLaunchFailed": "{{value0}} チャットを開けませんでした", - "handoff": { - "stage": { - "finishingChat": "チャットセッションを終了しています…", - "finishingTerminal": "仕上げ Agent ターミナル…", - "openingTerminal": "Agent ターミナルを開いています…", - "resumingChat": "チャットセッションを再開しています…", - "verifyingTerminal": "Agent ターミナルを確認しています…", - "verifyingChat": "チャットセッションを確認しています…", - "recovering": "Agent セッションを回復しています…", - "manualRecovery": "Agent セッションの回復が必要です" - }, - "switchingOwner": "セッション所有者を切り替えています…", - "mode": { - "switching": "スイッチング", - "terminal": "ターミナル", - "chat": "チャット" - }, - "switchingAfterTurn": "このターン後に切り替える", - "returningAfterTurn": "このターン後に戻る", - "cancel": "キャンセル", - "switchAfterTurn": "このターン後に切り替える", - "stopTurnAndSwitch": "回転を止めて切り替えます", - "openAgentTui": "Agent TUI を開く", - "returnAfterTurn": "このターン後に戻る", - "returnToChat": "チャットに戻る", - "agentOpenOnHost": "Agent は {{value0}} のターミナルで開いています。", - "agentOpen": "Agent はターミナルで開いています。", - "exitTerminal": "Agent ターミナルを終了してチャットを続行します。", - "retryProof": "再試行証明", - "retry": "再試行", - "details": "詳細" - }, "structuredSessionLaunchFailedDescription": "Orca は構造化された {{value0}} チャットを開けませんでした。詳細についてはログを参照してください。", "subagents": { "state": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0386126ae396..9d1f7e398f81 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17536,38 +17536,6 @@ "questionCount": "{{value0}} 질문" }, "structuredSessionLaunchFailed": "{{value0}} 채팅을 열 수 없습니다.", - "handoff": { - "stage": { - "finishingChat": "채팅 세션을 종료하는 중…", - "finishingTerminal": "마감제 터미널…", - "openingTerminal": "에이전트 터미널을 여는 중…", - "resumingChat": "채팅 세션을 재개하는 중…", - "verifyingTerminal": "에이전트 터미널 확인 중…", - "verifyingChat": "채팅 세션 확인 중…", - "recovering": "에이전트 세션을 복구하는 중…", - "manualRecovery": "에이전트 세션을 복구해야 합니다." - }, - "switchingOwner": "세션 소유자 전환 중…", - "mode": { - "switching": "스위칭", - "terminal": "터미널", - "chat": "채팅" - }, - "switchingAfterTurn": "이번 턴 이후에 전환", - "returningAfterTurn": "이번 턴 이후에 돌아옴", - "cancel": "취소", - "switchAfterTurn": "이번 턴 이후에 전환", - "stopTurnAndSwitch": "회전을 멈추고 스위치를 켜세요", - "openAgentTui": "에이전트 TUI 공개", - "returnAfterTurn": "이번 턴 이후에 돌아오세요", - "returnToChat": "채팅으로 돌아가기", - "agentOpenOnHost": "에이전트가 {{value0}}의 터미널에 열려 있습니다.", - "agentOpen": "에이전트가 터미널에 열려 있습니다.", - "exitTerminal": "채팅을 계속하려면 에이전트 터미널을 종료하세요.", - "retryProof": "재시도 증명", - "retry": "다시 시도", - "details": "세부" - }, "structuredSessionLaunchFailedDescription": "Orca는 구조화된 {{value0}} 채팅을 열 수 없습니다. 자세한 내용은 로그를 참조하세요.", "subagents": { "state": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b88637923cd6..b8370fcf76e8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17501,38 +17501,6 @@ "questionCount": "{{value0}} 问题" }, "structuredSessionLaunchFailed": "无法打开 {{value0}} 聊天", - "handoff": { - "stage": { - "finishingChat": "正在完成聊天会话...", - "finishingTerminal": "整理代理终端...", - "openingTerminal": "打开代理终端...", - "resumingChat": "正在恢复聊天会话...", - "verifyingTerminal": "正在验证代理终端...", - "verifyingChat": "正在验证聊天会话...", - "recovering": "正在恢复代理会话...", - "manualRecovery": "代理会话需要恢复" - }, - "switchingOwner": "切换会话所有者...", - "mode": { - "switching": "交换", - "terminal": "终端", - "chat": "聊天" - }, - "switchingAfterTurn": "本回合后切换", - "returningAfterTurn": "本回合后返回", - "cancel": "取消", - "switchAfterTurn": "本回合后切换", - "stopTurnAndSwitch": "停止转弯并切换", - "openAgentTui": "打开代理 TUI", - "returnAfterTurn": "本回合后返回", - "returnToChat": "返回聊天", - "agentOpenOnHost": "代理已在 {{value0}} 的终端中打开。", - "agentOpen": "代理在终端中打开。", - "exitTerminal": "退出代理终端以继续聊天。", - "retryProof": "重试证明", - "retry": "重试", - "details": "细节" - }, "structuredSessionLaunchFailedDescription": "Orca 无法打开结构化 {{value0}} 聊天。有关详细信息,请参阅日志。", "subagents": { "state": { diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 5209d9b134d3..a5f543b1479f 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -368,87 +368,6 @@ describe('worktree agent activation gate', () => { }) }) - it('adopts the exact structured TUI surface without creating a duplicate tab', async () => { - const tabId = 'structured-agent-session-live-session' - const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') - const livePtyId = `${WORKTREE_ID}@@live-agent` - const { deps, createTab, resume } = testDeps({ - sessions: [listed(livePtyId)], - sleeping: [live], - resumeCount: 0 - }) - const ownerTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d' - const ownerLeafId = '2659ee80-d3fc-454f-b4ea-0638de1ae345' - - await expect( - runWorktreeAgentActivationGate(WORKTREE_ID, { - ...deps, - hasStructuredSession: async () => ({ - snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), - ownerBySessionId: new Map([ - [ - 'live-session', - { - owner: 'tui', - terminal: { - paneKey: `${ownerTabId}:${ownerLeafId}`, - ptyId: livePtyId, - tabId: ownerTabId - } - } - ] - ]) - }) - }) - ).resolves.toBe('adopted') - - expect(createTab).toHaveBeenCalledOnce() - expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { - id: ownerTabId, - initialLeafId: ownerLeafId, - initialPtyId: livePtyId, - activate: false, - recordInteraction: false - }) - expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { - skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) - }) - }) - - it.each(['present', 'unknown'] as const)( - 'does not resume OMP when a surfaced %s agent lacks conversation ownership', - async (agentOwnership) => { - const record = { - ...sleepingRecord('old-tab', DEAD_LEAF_ID, 'omp-session'), - agent: 'omp' as const - } - const ptyId = `${WORKTREE_ID}@@current-omp` - const { deps, resume, createTab } = testDeps({ - sessions: [{ ...listed(ptyId), title: 'OMP', agentOwnership }], - sleeping: [record], - surfaceOwners: new Map([ - [ - ptyId, - { - ptyId, - tabId: 'current-tab', - paneKey: `current-tab:${LIVE_LEAF_ID}` - } - ] - ]) - }) - seedExistingSurface(deps.getState(), { - tabId: 'current-tab', - leafId: LIVE_LEAF_ID, - boundPtyId: ptyId - }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') - expect(resume).not.toHaveBeenCalled() - expect(createTab).not.toHaveBeenCalled() - expect(deps.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record) - } - ) - it('does not use an ambiguous tab binding as a live session claim', async () => { const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session') const livePtyId = `${WORKTREE_ID}@@live-agent` @@ -460,7 +379,8 @@ describe('worktree agent activation gate', () => { expect(resume).not.toHaveBeenCalled() }) - it('blocks when a structured TUI owner is absent from live inventory', async () => { + it('blocks when the host names no chat owner for a structured tab', async () => { + // An older host can still answer that a terminal owns the session; that is no chat owner. const tabId = 'structured-agent-session-live-session' const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') const livePtyId = `${WORKTREE_ID}@@live-agent` @@ -471,19 +391,7 @@ describe('worktree agent activation gate', () => { ...deps, hasStructuredSession: async () => ({ snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), - ownerBySessionId: new Map([ - [ - 'live-session', - { - owner: 'tui', - terminal: { - paneKey: live.paneKey, - ptyId: `${WORKTREE_ID}@@different-agent`, - tabId - } - } - ] - ]) + ownerBySessionId: new Map() }) }) ).resolves.toBe('blocked') @@ -491,35 +399,6 @@ describe('worktree agent activation gate', () => { expect(resume).not.toHaveBeenCalled() }) - it('uses the restored owner surface even when its tab predates structured naming', async () => { - const tabId = 'structured-agent-session-other-session' - const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') - const livePtyId = `${WORKTREE_ID}@@live-agent` - const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) - - await expect( - runWorktreeAgentActivationGate(WORKTREE_ID, { - ...deps, - hasStructuredSession: async () => ({ - snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), - ownerBySessionId: new Map([ - [ - 'live-session', - { - owner: 'tui', - terminal: { paneKey: live.paneKey, ptyId: livePtyId, tabId } - } - ] - ]) - }) - }) - ).resolves.toBe('resumed') - - expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { - skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) - }) - }) - it('suppresses the exact structured session when native already owns it', async () => { const tabId = 'structured-agent-session-live-session' const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index ae650c0e1183..12ea8d33eb2c 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -12,7 +12,6 @@ import { import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership' import { adoptLiveWorkspacePtySurfaces, - bindLivePtyToExactSurface, type LiveSurfaceAdoptionStore } from './worktree-agent-live-surface-adoption' import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners' @@ -129,11 +128,7 @@ function liveSleepingAgentClaims( keys.add(getProviderSessionClaimKey(record)) continue } - // Packaged hydration can omit renderer bindings while main retains this session's exact TUI. - const structuredOwnerPtyId = - structuredOwner?.owner === 'tui' ? structuredOwner.terminal?.ptyId : undefined - const persistedPtyId = - layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId + const persistedPtyId = layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) if (persistedPtyId && livePtyIds.has(persistedPtyId)) { claimedPtyIds.add(persistedPtyId) keys.add(getProviderSessionClaimKey(record)) @@ -169,16 +164,7 @@ export async function runWorktreeAgentActivationGate( const structuredTabs = structuredInventory?.snapshot.tabs.filter( (tab) => tab.type === 'agent-session' ) - if ( - structuredTabs?.some((tab) => { - const owner = structuredInventory?.ownerBySessionId.get(tab.sessionId) - return ( - !owner || - (owner.owner === 'tui' && - (!owner.terminal || parsePaneKey(owner.terminal.paneKey)?.tabId !== owner.terminal.tabId)) - ) - }) - ) { + if (structuredTabs?.some((tab) => !structuredInventory?.ownerBySessionId.has(tab.sessionId))) { return 'blocked' } if ( @@ -206,18 +192,6 @@ export async function runWorktreeAgentActivationGate( sessionBelongsToWorkspace(session.id, worktreeId) ) const liveWorkspacePtyIds = new Set(liveWorkspaceSessions.map((session) => session.id)) - for (const owner of structuredInventory?.ownerBySessionId.values() ?? []) { - if (owner.owner !== 'tui') { - continue - } - if ( - !owner.terminal || - !liveWorkspacePtyIds.has(owner.terminal.ptyId) || - !bindLivePtyToExactSurface(deps.getState(), worktreeId, owner.terminal) - ) { - return 'blocked' - } - } let liveSurfaceAdopted = false if (liveWorkspaceSessions.length > 0) { // Why: an unreadable census adopts nothing and mints nothing, so reporting 'adopted' diff --git a/src/renderer/src/lib/worktree-agent-structured-inventory.ts b/src/renderer/src/lib/worktree-agent-structured-inventory.ts index b541e363504a..165ce1cfa705 100644 --- a/src/renderer/src/lib/worktree-agent-structured-inventory.ts +++ b/src/renderer/src/lib/worktree-agent-structured-inventory.ts @@ -4,13 +4,7 @@ import { worktreeIdsEqual } from '../../../shared/worktree/id' export type StructuredActivationInventory = { snapshot: RuntimeMobileSessionTabsResult - ownerBySessionId: ReadonlyMap< - string, - { - owner: 'native' | 'tui' - terminal?: { paneKey: string; ptyId: string; tabId: string } - } - > + ownerBySessionId: ReadonlyMap } export async function readWorktreeStructuredActivationInventory( @@ -38,13 +32,7 @@ export async function readWorktreeStructuredActivationInventory( if (!snapshot.tabs.some((tab) => tab.type === 'agent-session')) { return false } - const ownerBySessionId = new Map< - string, - { - owner: 'native' | 'tui' - terminal?: { paneKey: string; ptyId: string; tabId: string } - } - >() + const ownerBySessionId = new Map() await Promise.all( snapshot.tabs.flatMap((tab) => tab.type === 'agent-session' @@ -55,27 +43,14 @@ export async function readWorktreeStructuredActivationInventory( if (!statusResponse.ok) { return } - const status = statusResponse.result as { - owner?: unknown - terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown } - } - if (status.owner === 'native') { - ownerBySessionId.set(tab.sessionId, { owner: 'native' }) - } else if ( - status.owner === 'tui' && - typeof status.terminal?.paneKey === 'string' && - typeof status.terminal?.ptyId === 'string' && - status.terminal.ptyId.length > 0 && - typeof status.terminal.tabId === 'string' + const status: unknown = statusResponse.result + if ( + typeof status === 'object' && + status !== null && + 'owner' in status && + status.owner === 'native' ) { - ownerBySessionId.set(tab.sessionId, { - owner: 'tui', - terminal: { - paneKey: status.terminal.paneKey, - ptyId: status.terminal.ptyId, - tabId: status.terminal.tabId - } - }) + ownerBySessionId.set(tab.sessionId, { owner: 'native' }) } }) ] diff --git a/src/renderer/src/runtime/structured-agent-session-handoff-store.ts b/src/renderer/src/runtime/structured-agent-session-handoff-store.ts deleted file mode 100644 index 8800f3498028..000000000000 --- a/src/renderer/src/runtime/structured-agent-session-handoff-store.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useSyncExternalStore } from 'react' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' - -export type TerminalStructuredHandoff = { - sessionId: string - fence: number - status: AgentSessionHandoffStatus -} - -const byTerminalTabId = new Map() -const listeners = new Set<() => void>() - -export function publishStructuredHandoff(input: TerminalStructuredHandoff): void { - for (const [tabId, current] of byTerminalTabId) { - if (current.sessionId === input.sessionId && tabId !== input.status.terminal?.tabId) { - byTerminalTabId.delete(tabId) - } - } - if (input.status.terminal) { - byTerminalTabId.set(input.status.terminal.tabId, input) - } - for (const listener of listeners) { - listener() - } -} - -export function clearStructuredHandoff(sessionId: string): void { - let changed = false - for (const [tabId, current] of byTerminalTabId) { - if (current.sessionId === sessionId) { - byTerminalTabId.delete(tabId) - changed = true - } - } - if (changed) { - for (const listener of listeners) { - listener() - } - } -} - -export function useTerminalStructuredHandoff(tabId: string): TerminalStructuredHandoff | null { - return useSyncExternalStore( - (listener) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - () => byTerminalTabId.get(tabId) ?? null - ) -} diff --git a/src/shared/agent-session-definitive-refusal.test.ts b/src/shared/agent-session-definitive-refusal.test.ts index 74dd3529cf65..733cad728949 100644 --- a/src/shared/agent-session-definitive-refusal.test.ts +++ b/src/shared/agent-session-definitive-refusal.test.ts @@ -37,12 +37,9 @@ describe('definitive agent-session create refusals', () => { it('does not answer the durable-settlement question, which disagrees on the one code that matters', () => { // Guards the reuse this allowlist exists to avoid: settlement state calls the definitive // refusal pending-admission, which would rule out the fallback it is meant to allow. - expect( - agentSessionRefusalOperationState( - 'agentSession.create', - 'structured_agent_session_unsupported' - ) - ).toBe('pending-admission') + expect(agentSessionRefusalOperationState('structured_agent_session_unsupported')).toBe( + 'pending-admission' + ) expect(isDefinitiveAgentSessionCreateRefusal('structured_agent_session_unsupported')).toBe(true) }) }) diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index dd8b5433efdd..9f6043834e10 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -144,7 +144,8 @@ describe('acquisition compare-and-swap', () => { it.each([ ['recovering', 'agent_session_ownership_unknown'], ['manual-recovery', 'agent_session_ownership_unknown'], - ['preparing', 'agent_session_conflict'] + ['preparing', 'agent_session_conflict'], + ['old-owner-stopped', 'agent_session_conflict'] ] as const)('refuses acquisition in stage %s', (handoffStage, code) => { expect(acquire(lease({ handoffStage }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', @@ -152,25 +153,22 @@ describe('acquisition compare-and-swap', () => { }) }) - it.each(['old-owner-stopped', 'new-owner-proving'] as const)( - 'refuses a different handoff operation and replays the matching one at %s', - (handoffStage) => { - const mid = lease({ - handoffStage, - handoffOperationId: 'op-1', - ownerProcess: null, - claimStatus: 'reserved' - }) - expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ - decision: 'refused', - code: 'agent_session_operation_conflict' - }) - expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ - decision: 'retry-reservation', - fence: 7 - }) - } - ) + it('refuses a different acquisition operation and replays the matching one', () => { + const mid = lease({ + handoffStage: 'new-owner-proving', + handoffOperationId: 'op-1', + ownerProcess: null, + claimStatus: 'reserved' + }) + expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict' + }) + expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ + decision: 'retry-reservation', + fence: 7 + }) + }) it('refuses a reservation whose spawn may have won the race with the crash', () => { const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage: null }) @@ -186,14 +184,14 @@ describe('acquisition compare-and-swap', () => { }) describe('restart reconciliation', () => { - it('re-adopts a proven-live TUI owner without moving the fence', () => { + it('routes a surviving terminal owner to recovery; nothing re-adopts a terminal', () => { expect( adjudicateAgentSessionRestart({ lease: lease({ runtimeKind: 'tui' }), probe: MATCHED, observedAt: 9_000 }) - ).toEqual({ disposition: 'readopt' }) + ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) }) it('routes a surviving native owner to recovery instead of readopting a dead transport', () => { diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index 18887a115e02..28ba0270740b 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -42,12 +42,11 @@ export type AgentSessionLeaseRefusalCode = export type AgentSessionAcquisitionDecision = | { decision: 'granted'; nextFence: number } - /** The same handoff operation re-entering its own reservation; no new fence, no new spawn. */ + /** The same acquisition operation re-entering its own reservation; no new fence, no new spawn. */ | { decision: 'retry-reservation'; fence: number } | { decision: 'refused'; code: AgentSessionLeaseRefusalCode } export type AgentSessionRestartAdjudication = - | { disposition: 'readopt' } /** A journal settlement latch survives restart without changing its handoff stage. */ | { disposition: 'settlement-pending' } /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ @@ -63,12 +62,6 @@ export function agentSessionRestartEvictionSettlementId( return `restart-eviction:${lease.sessionId}:${eviction.nextFence}` } -/** Stages that can legally admit a new owner at all; the rest have an owner or no evidence. */ -const STAGES_ADMITTING_NEW_OWNER: ReadonlySet = new Set([ - 'old-owner-stopped', - 'new-owner-proving' -]) - export function isProvenDeadProbe(probe: AgentSessionOwnerProbe): boolean { return ( probe.outcome === 'exit-observed' || @@ -152,11 +145,8 @@ export function evaluateAgentSessionAcquisition(args: { // Why: no stage expires into an owner; recovery is resolved by proof or by the user. return { decision: 'refused', code: 'agent_session_ownership_unknown' } } - if (lease.handoffStage === 'preparing') { - // Why: the old owner is quiesced but alive and still authoritative. - return { decision: 'refused', code: 'agent_session_conflict' } - } - if (lease.handoffStage !== null && !STAGES_ADMITTING_NEW_OWNER.has(lease.handoffStage)) { + if (lease.handoffStage !== null && lease.handoffStage !== 'new-owner-proving') { + // Why: a terminal-handoff stage from an older build stays refused until restart retires it. return { decision: 'refused', code: 'agent_session_conflict' } } if (lease.handoffStage !== null && lease.handoffOperationId !== null) { @@ -166,7 +156,6 @@ export function evaluateAgentSessionAcquisition(args: { } if ( lease.ownerProcess === null && - STAGES_ADMITTING_NEW_OWNER.has(lease.handoffStage) && lease.claimStatus === 'reserved' && lease.reservedSpawnToken !== null ) { @@ -246,17 +235,14 @@ export function adjudicateAgentSessionRestart(args: { } } if (isProvenAliveProbe(probe)) { - if (lease.runtimeKind === 'native') { - // Why: the surviving child's stdio died with the previous runtime, so readoption - // would renew a lease no host can drive. Recovery stops it and respawns at fence + 1. - return { - disposition: 'recovering', - stage: 'recovering', - reason: 'native owner outlived the runtime that held its transport' - } + // Why: the surviving child's stdio died with the previous runtime, so readoption would renew + // a lease no host can drive. Recovery stops a chat child and respawns at fence + 1; a terminal + // owner an older build recorded is waited out, never stopped. + return { + disposition: 'recovering', + stage: 'recovering', + reason: 'owner outlived the runtime that held its transport' } - // Why: re-adoption is not a new generation, so the fence does not move. - return { disposition: 'readopt' } } const evidence = deathEvidenceFor(probe, observedAt) if (evidence) { diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index bc7f5761db76..ea99929c6185 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -149,12 +149,6 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR message: `The session is mid-handoff (${lease.handoffStage}).` } } - if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { - return { - code: 'agent_session_conflict', - message: 'The agent terminal owns this session.' - } - } return { code: 'agent_session_ownership_unknown', message: 'The session has no live owner to accept writes.' diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index f5e9a1a7735e..8763674df1cd 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -51,8 +51,13 @@ export type AgentSessionLaunchEnv = Record /** Provider CLI arguments captured by the host when the session is created. */ export type AgentSessionLaunchArgs = string[] +/** `tui` is read-only history: only the removed terminal handoff wrote it, and restart recovery + * waits a recorded terminal owner out rather than adopting or stopping it. */ export type AgentSessionOwnerRuntimeKind = 'native' | 'tui' +/** The acquisition stage. `preparing` and `old-owner-stopped` were written only by the removed + * terminal handoff and stay here so older records load; restart reconciliation and settlement + * retry overwrite them like any other stage. */ export type AgentSessionHandoffStage = | 'preparing' | 'old-owner-stopped' diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts index 75ce023b6ef2..9777cfd42d1e 100644 --- a/src/shared/agent-session-refusal-retry.ts +++ b/src/shared/agent-session-refusal-retry.ts @@ -2,20 +2,9 @@ import type { AgentSessionWireRefusalCode } from './agent-session-wire' export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown' -const HANDOFF_SETTLED_REFUSALS = new Set([ - 'structured_agent_session_unsupported', - 'agent_session_checkpoint_stale', - 'agent_session_conflict', - 'agent_session_operation_conflict' -]) - export function agentSessionRefusalOperationState( - method: string, code: AgentSessionWireRefusalCode ): AgentSessionRefusalOperationState { - if (method === 'agentSession.requestHandoff' && HANDOFF_SETTLED_REFUSALS.has(code)) { - return 'settled-rejected' - } switch (code) { // The host tried to restart the owner for this send and could not; a Retry tries again. case 'agent_session_owner_restart_failed': diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 1e8853bd546f..e6e193b40dd1 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -34,13 +34,11 @@ import { import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' -export type AgentSessionHandoffDirection = 'to-tui' | 'to-native' -export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn' -export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' | 'recover' - +/** `agentSession.handoffStatus`. Named for the removed terminal handoff; released desktop clients + * still read `owner`, and older hosts can still send the terminal-era fields. */ export type AgentSessionHandoffStatus = { owner: AgentSessionOwnerRuntimeKind | 'none' - direction: AgentSessionHandoffDirection | null + direction: 'to-tui' | 'to-native' | null phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed' stage: AgentSessionHandoffStage | null operationId: string | null @@ -59,15 +57,6 @@ export type AgentSessionHandoffStatus = { } } -export type AgentSessionHandoffRequest = { - envelope: AgentSessionMutationEnvelope - direction: AgentSessionHandoffDirection - mode: AgentSessionHandoffMode - action?: AgentSessionHandoffAction -} - -export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus } - export type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState, @@ -163,7 +152,6 @@ export type AgentSessionSubscribeEvent = sessionId: string page: AgentSessionHistoryPage fence: number - handoff?: AgentSessionHandoffStatus backgroundTasks?: AgentSessionBackgroundTaskState | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null @@ -174,9 +162,8 @@ export type AgentSessionSubscribeEvent = type: 'batch' sessionId: string batch: AgentSessionJournalBatch - /** Added with handoff state so mixed-version cursors retain the ownership fence. */ + /** Optional so mixed-version cursors retain the ownership fence. */ fence?: number - handoff?: AgentSessionHandoffStatus backgroundTasks?: AgentSessionBackgroundTaskState | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null @@ -189,7 +176,6 @@ export type AgentSessionSubscribeEvent = reset: AgentJournalResetReason page: AgentSessionHistoryPage fence: number - handoff?: AgentSessionHandoffStatus backgroundTasks?: AgentSessionBackgroundTaskState | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index e907b413263e..57d90d2218a2 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -468,7 +468,6 @@ import { ConversationCommandParams, CreateParams, CreateSupportParams, - HandoffParams, HandoffStatusParams, HistoryParams, HoldParams, @@ -579,7 +578,6 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.modelCatalog': ModelCatalogParams, 'agentSession.options': OptionsParams, 'agentSession.release': HoldParams, - 'agentSession.requestHandoff': HandoffParams, 'agentSession.respondToApproval': RespondParams, 'agentSession.respondToQuestion': RespondParams, 'agentSession.restartContinue': RestartResumeParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index e62d56468947..7f41bd2c4b8a 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -96,7 +96,7 @@ export const AttachParams = z provider: z.enum(['codex', 'claude']), agent: Identifier('Invalid agent'), accountHome: AccountHome, - runtimeKind: z.enum(['native', 'tui']), + runtimeKind: z.literal('native'), providerHandle: ProviderHandle }) .strict() @@ -216,15 +216,6 @@ export const SetOptionParams = z }) .strict() -export const HandoffParams = z - .object({ - envelope: MutationEnvelope, - direction: z.enum(['to-tui', 'to-native']), - mode: z.enum(['now', 'after-turn', 'stop-turn']), - action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() - }) - .strict() - export const OptionsParams = z.object({ sessionId: SessionId }).strict() /** `sessionId` scopes the catalog to that session's pinned account; without a diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index d3c8cacffb7d..17de32fd71b8 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -38,7 +38,6 @@ function mergeBatch( ...(right.fence !== undefined || left.fence !== undefined ? { fence: right.fence ?? left.fence } : {}), - ...(right.handoff || left.handoff ? { handoff: right.handoff ?? left.handoff } : {}), ...(right.backgroundTasks !== undefined || left.backgroundTasks !== undefined ? { backgroundTasks: diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index b2855ac1021d..a336dea6fedd 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -79,7 +79,7 @@ export function requeueStructuredAgentSessionSendRefusal( createOperationId: () => string, retainOperationId = false ): StructuredAgentSessionOutboxEntry { - const refusalState = agentSessionRefusalOperationState('agentSession.send', code) + const refusalState = agentSessionRefusalOperationState(code) if ( refusalState !== 'settled-rejected' || retainOperationId || diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 37baf39cb43f..2f839f933309 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -6,7 +6,6 @@ import type { import type { AgentSessionBackgroundTaskState, AgentSessionSlashCommand, - AgentSessionHandoffStatus, AgentSessionHistoryPage, AgentSessionSubscribeEvent, AgentSessionTurnActivity @@ -33,7 +32,6 @@ export type StructuredAgentSessionState = { hasOlder: boolean status: 'idle' | 'loading' | 'ready' | 'error' error?: string - handoff: AgentSessionHandoffStatus | null backgroundTasks?: AgentSessionBackgroundTaskState | null commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null @@ -47,7 +45,6 @@ export type StructuredAgentSessionState = { export type StructuredAgentSessionAction = | { type: 'loading' } | { type: 'error'; message: string } - | { type: 'handoff'; handoff: AgentSessionHandoffStatus } | { type: 'event'; event: AgentSessionSubscribeEvent } | { type: 'history-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage } @@ -65,8 +62,7 @@ export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { submissions: [], retainedItemLimit: MAX_RETAINED_ITEMS, hasOlder: false, - status: 'idle', - handoff: null + status: 'idle' } /** A frame without `hostNow` (older host) leaves the previous sample in place. */ @@ -82,7 +78,6 @@ function hostClockField( function replacePage( page: AgentSessionHistoryPage, fence: number | null, - handoff?: AgentSessionHandoffStatus, backgroundTasks?: AgentSessionBackgroundTaskState | null, activity?: AgentSessionTurnActivity | null ): StructuredAgentSessionState { @@ -95,7 +90,6 @@ function replacePage( retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, page.items.length), hasOlder: page.hasOlder, status: 'ready', - handoff: handoff ?? null, activity: activity ?? null, ...(backgroundTasks !== undefined ? { backgroundTasks } @@ -186,18 +180,9 @@ export function reduceStructuredAgentSession( if (action.type === 'error') { return { ...state, status: 'error', error: action.message } } - if (action.type === 'handoff') { - return { ...state, handoff: action.handoff } - } if (action.type === 'history-page') { return { - ...replacePage( - action.page, - action.page.fence ?? null, - state.handoff ?? undefined, - state.backgroundTasks, - state.activity - ), + ...replacePage(action.page, action.page.fence ?? null, state.backgroundTasks, state.activity), commands: state.commands, ...hostClockField(action.page.hostNow, receivedAt, state.hostClock) } @@ -230,7 +215,7 @@ export function reduceStructuredAgentSession( } if (event.type === 'snapshot' || event.type === 'reset') { return { - ...replacePage(event.page, event.fence, event.handoff, event.backgroundTasks, event.activity), + ...replacePage(event.page, event.fence, event.backgroundTasks, event.activity), commands: event.commands, ...hostClockField(event.hostNow, receivedAt, state.hostClock) } @@ -253,7 +238,6 @@ export function reduceStructuredAgentSession( event.batch.cursor.sequence === state.cursor?.sequence && journalUnchanged && (event.fence === undefined || event.fence === state.fence) && - (event.handoff === undefined || event.handoff === state.handoff) && (event.commands === undefined || event.commands === state.commands) && backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) && activity?.turnId === state.activity?.turnId && @@ -287,7 +271,6 @@ export function reduceStructuredAgentSession( : mergeSubmissions(state.submissions, event.batch.submissions, items), status: 'ready', error: undefined, - handoff: event.handoff ?? state.handoff, commands: event.commands !== undefined ? event.commands : state.commands, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(activity !== undefined ? { activity } : {}), diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 1b2bebd5a80f..7af9283f328a 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -70,7 +70,6 @@ export function structuredHostStub( respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), changeThreadGoal: vi.fn(async () => ({ ok: true, replayed: false })), - requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })), handoffStatus: vi.fn(async () => ({ owner: 'native' })), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), modelCatalog: vi.fn(() => ({ origin: 'unknown' as const })), diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index db75fcd5f8fd..d8c8b80b3293 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -90,11 +90,6 @@ export const STRUCTURED_CALLS: { hostMethod: 'changeThreadGoal', result: { ok: true, replayed: false } }, - { - method: 'agentSession.requestHandoff', - hostMethod: 'requestHandoff', - result: { status: { owner: 'native' } } - }, { method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus', @@ -262,14 +257,6 @@ export function paramsFor(method: string): unknown { const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } return { envelope: envelope({ method, fields, fence }), ...fields } } - case 'agentSession.requestHandoff': { - const fields = { - direction: 'to-tui' as const, - mode: 'now' as const, - action: 'start' as const - } - return { envelope: envelope({ method, fields, fence }), ...fields } - } case 'agentSession.setOption': { const fields = { key: 'model', value: 'gpt-5' } return { envelope: envelope({ method, fields, fence }), ...fields } From eab4ef1edf979be4301413c73a080bb263d82b36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:54:27 -0700 Subject: [PATCH 002/101] fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude --- .../structured-agent-session-eviction.test.ts | 25 +++++++++++++++++++ .../structured-agent-session-eviction.ts | 6 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index ff4e690677e6..e565955abcd7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -94,6 +94,31 @@ describe('structured agent session eviction', () => { ]) }) + it('still stops the child when the pre-stop snapshot cannot drain the sink', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const ctx = context() + const snapshot = vi.fn() + ctx.beforeProviderChildStop = snapshot + // A journal write that never settles, then a healthy sink once the child is stopped. + ctx.eventSink.drained = vi + .fn() + .mockReturnValueOnce(new Promise(() => {})) + .mockResolvedValue({ + ok: true + }) as unknown as StructuredAgentSessionEvictionContext['eventSink']['drained'] + + const eviction = evictStructuredAgentSession(ctx) + await vi.advanceTimersByTimeAsync(1_000) + + expect(snapshot).toHaveBeenCalledOnce() + expect(ctx.adapter.closeSession).toHaveBeenCalledOnce() + await eviction + } finally { + vi.useRealTimers() + } + }) + it('aborts after a failed drain barrier without unbinding or forgetting the session', async () => { const ctx = context() ctx.eventSink.drained = vi.fn(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index ab22f5c41d8d..951bbd2ab322 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -23,6 +23,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string @@ -50,6 +51,9 @@ export type StructuredAgentSessionEvictionContext = { releaseLease: () => Promise } +/** The resume offer is advisory; a stalled sink must not hold the child's stop behind it. */ +const SNAPSHOT_DRAIN_TIMEOUT_MS = 1_000 + export type StructuredAgentSessionEvictionStep = { name: string run: (context: StructuredAgentSessionEvictionContext) => Promise | void @@ -65,7 +69,7 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe return } // Events the provider already delivered are part of what the sidebar showed at the stop. - await context.eventSink.drained() + await withTimeout(context.eventSink.drained(), SNAPSHOT_DRAIN_TIMEOUT_MS, null) try { context.beforeProviderChildStop() } catch { From abafbbcc08ea78724991572196fe01a1ac279a65 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:54:29 -0700 Subject: [PATCH 003/101] refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude --- src/main/claude-accounts/environment.ts | 28 ------------------- .../windows-foreground-process-rows.test.ts | 13 +++++---- .../windows-foreground-process-rows.ts | 16 +---------- src/main/runtime/orca-runtime-core.ts | 6 ---- 4 files changed, 8 insertions(+), 55 deletions(-) diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index b85dd60a8542..3f7e18934629 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -84,34 +84,6 @@ export function shouldStripClaudeAuthEnvForAccount( * authenticate nor beat the pinned account, while the strip removes the name regardless. * Refusing it would break a terminal launch that works today for no security gain. */ -/** - * The inherited Anthropic auth a non-stripping launch has to carry forward explicitly. - * - * applyClaudeEnvPatch always strips the inherited half of a child env, and the - * configured half is what overrides it — so a system-auth user's own key only survives - * if the caller puts it back deliberately. Returns the exact keys present, so a - * win32 `anthropic_api_key` is carried under the name the OS actually has. - */ -export function claudeAuthEnvCarriedForward( - inherited: NodeJS.ProcessEnv, - platform: NodeJS.Platform = process.platform -): Record { - const carried: Record = {} - for (const [key, value] of Object.entries(inherited)) { - if (value === undefined) { - continue - } - const normalized = platform === 'win32' ? key.toUpperCase() : key - if ( - CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized) || - (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) - ) { - carried[key] = value - } - } - return carried -} - export function hasClaudeAuthEnvConflict( env: Record | undefined, platform: NodeJS.Platform = process.platform diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts index 42330dd6fe87..106d1baccc41 100644 --- a/src/main/providers/windows-foreground-process-rows.test.ts +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -19,7 +19,6 @@ import { import { queryWindowsProcessDescendants, queryWindowsProcessLinksFresh, - queryWindowsProcessRowsFresh, resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' // A real snapshot always contains the process doing the querying; the reader @@ -85,7 +84,7 @@ describe('windows process rows', () => { }) resetWindowsProcessRowsSnapshotForTests() - await expect(queryWindowsProcessRowsFresh()).rejects.toThrow(/unreadable/) + expect(await queryWindowsProcessDescendants(100, { fresh: true })).toBeNull() }) it('reports an unreadable table as unavailable, not as an empty machine', async () => { @@ -96,7 +95,7 @@ describe('windows process rows', () => { }) resetWindowsProcessRowsSnapshotForTests() - await expect(queryWindowsProcessRowsFresh()).rejects.toThrow() + expect(await queryWindowsProcessDescendants(100, { fresh: true })).toBeNull() expect(await queryWindowsProcessDescendants(100)).toBeNull() }) @@ -107,17 +106,19 @@ describe('windows process rows', () => { it('collapses a burst of concurrent identity probes into one scan', async () => { // A worktree delete tears down PTYs 32-wide. - const rows = await Promise.all(Array.from({ length: 32 }, () => queryWindowsProcessRowsFresh())) + const rows = await Promise.all( + Array.from({ length: 32 }, () => queryWindowsProcessDescendants(100, { fresh: true })) + ) expect(scanCount()).toBe(1) - expect(rows[31]?.map((row) => row.pid)).toEqual([process.pid, 100, 200]) + expect(rows[31]?.map((row) => row.pid)).toEqual([200]) }) it('never answers from the TTL cache, which can predate the recycle it detects', async () => { await queryWindowsProcessDescendants(100) expect(scanCount()).toBe(1) - await queryWindowsProcessRowsFresh() + await queryWindowsProcessDescendants(100, { fresh: true }) expect(scanCount()).toBe(2) }) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index 16f01d5fbe7e..de24a2717129 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -51,21 +51,7 @@ function projectProcessRows(native: readonly NativeWindowsProcessRow[]): Windows } /** - * Rows from a scan that starts after this call. - * - * PID-identity checks in teardown must not reuse a cached row — it can predate - * the very recycle it is meant to detect. Rejects when the table is unreadable, - * so "unavailable" stays distinguishable from "nothing is running". - * - * `readonly` because the projection is shared with every other reader of the - * same snapshot. - */ -export async function queryWindowsProcessRowsFresh(): Promise { - return projectProcessRows(await readWindowsProcessTableFresh()) -} - -/** - * The same fresh scan for an ancestry walk, which reads only pid/ppid. + * A fresh scan for an ancestry walk, which reads only pid/ppid. * * Returns identity rows so the command line is not merely unused but absent: * asking for it costs an `OpenProcess` per process on the box. diff --git a/src/main/runtime/orca-runtime-core.ts b/src/main/runtime/orca-runtime-core.ts index f2c7cf4405de..bf5821215e8f 100644 --- a/src/main/runtime/orca-runtime-core.ts +++ b/src/main/runtime/orca-runtime-core.ts @@ -4,7 +4,6 @@ import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-termi import type { ResolvedWorktree } from './runtime-worktree-path-identity' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import { isCursorAgentTitle } from '../../shared/agent-detection' -import { isAbsolute, relative, resolve } from 'node:path' import { IMMEDIATE_KILL_REPLY_BUDGET_MS } from '../daemon/immediate-kill-reply-budget' import type { RuntimeTerminalDriverState, @@ -274,11 +273,6 @@ export function addListenerToMap( } } -export function isPathWithinDirectory(directory: string, candidate: string): boolean { - const relativePath = relative(resolve(directory), resolve(candidate)) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', From 3cbf6b58f467764a9c85208d96d1c9fc2f04fa56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:56:29 -0700 Subject: [PATCH 004/101] docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude --- .../agent-session-wire/agent-session-empty-batch.ts | 2 +- .../agent-session-wire/structured-agent-session-attach.ts | 2 +- .../structured-agent-session-host-lifetime.ts | 4 ++-- .../agent-session-wire/structured-agent-session-host-types.ts | 4 ++-- .../structured-agent-session-status-feed.ts | 4 ++-- .../structured-agent-session-unexpected-exit.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/agent-session-empty-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-empty-batch.ts index bc713f92171e..fa4289da396a 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-empty-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-empty-batch.ts @@ -1,7 +1,7 @@ import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' -/** A batch that advances nothing: the carrier for fence, handoff, roster, and clock updates. */ +/** A batch that advances nothing: the carrier for fence, roster, and clock updates. */ export function emptyAgentSessionBatch(cursor: AgentJournalCursor): AgentSessionJournalBatch { return { cursor, items: [], removedItemIds: [], submissions: [] } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 4a8bdd7c0a0e..38595d4e6eb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -181,7 +181,7 @@ export type AttachedJournal = { * record store handed this host the lease and before `onAttached` starts a * provider child, so nothing can be appending to the provider's history while it * is read, and the window stays valid until the resume consumes it. Every other - * settlement site — a proven child exit, a handoff suspend — runs while the host + * settlement site — a proven child exit — runs while the host * may still start another child, and a read there could be overtaken before it * is acted on. Orca still never re-sends: this decides state only. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index c602d3a9bb9a..00df8b4e9104 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -56,8 +56,8 @@ function hasProviderChild( } /** The wind-down this host owes for the session's child. A live child always owes one, whatever a - * previous childless eviction recorded — the same session object is re-acquired in place on a - * handoff back to native, so a remembered `false` must never outrank the child in front of it. */ + * previous childless eviction recorded: a remembered `false` must never outrank the child in front + * of it. */ function owesProviderChildWindDown(session: StructuredAgentSessionHostSession): boolean { return session.hasProviderChild || session.owesProviderChildWindDown === true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 2ff77d4436d2..574ced3cc383 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -31,8 +31,8 @@ export type StructuredAgentSessionHostSession = { params: AgentSessionAttachParams fence: number /** Whether THIS host generation is running the provider process behind the session. A journal - * restored for reading has none, and neither has a session a TUI owns — so neither may be - * evicted to free a child, and neither may have its lease released as an observed exit. */ + * restored for reading has none — so it may not be evicted to free a child, nor have its lease + * released as an observed exit. */ hasProviderChild: boolean /** Whether the child behind `hasProviderChild` has proven its start. A publish-first acquire * is `starting` until the adapter's `started` event; only then are its reported options fact. */ 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 09a6248c0b72..29bf29a00f17 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 @@ -264,8 +264,8 @@ export class StructuredAgentSessionStatusFeed { } const record = this.deps.getRecord(sessionId) const providerSession = structuredAgentSessionProviderSessionMetadata(record) - // The journal has no model: the record's acknowledged options are where an owner - // handoff or a mid-session switch lands, so the row follows whichever is in force. + // The journal has no model: the record's acknowledged options are where a mid-session + // switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) // Usage is dropped here on purpose: a `task_progress` tick would otherwise fail the // equality check and re-broadcast a full summary to every remote subscriber for a diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 06740bfab25c..5363b8588e74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -73,7 +73,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< } const record = context.store.getRecord(unexpectedEvent.sessionId) if (!record || record.lease.handoffStage !== null) { - // The handoff coordinator owns an already-started transition. + // An acquisition or recovery already owns this lease's transition. session.hasProviderChild = false context.publishStatus?.(unexpectedEvent.sessionId) return null From e048579cf5b9e33ce3d2cfa5dc027156a72bfe63 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:57:28 -0700 Subject: [PATCH 005/101] test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude --- .../structured-agent-session-eviction.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index e565955abcd7..cb0cb2fb2334 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -101,12 +101,7 @@ describe('structured agent session eviction', () => { const snapshot = vi.fn() ctx.beforeProviderChildStop = snapshot // A journal write that never settles, then a healthy sink once the child is stopped. - ctx.eventSink.drained = vi - .fn() - .mockReturnValueOnce(new Promise(() => {})) - .mockResolvedValue({ - ok: true - }) as unknown as StructuredAgentSessionEvictionContext['eventSink']['drained'] + vi.mocked(ctx.eventSink.drained).mockReturnValueOnce(new Promise(() => {})) const eviction = evictStructuredAgentSession(ctx) await vi.advanceTimersByTimeAsync(1_000) From b50c57472efa3b201a888196c6ee2cf04fd500ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:00:25 -0700 Subject: [PATCH 006/101] test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude --- ...-session-eviction-settlement-latch.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts b/src/main/runtime/agent-session-eviction-settlement-latch.test.ts index c6ae4fc2ffa1..ebb893ef7e54 100644 --- a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts +++ b/src/main/runtime/agent-session-eviction-settlement-latch.test.ts @@ -30,6 +30,32 @@ describe('proven-dead agent session eviction settlement', () => { }) }) + it('owes no settlement for a start that died before proving its handle', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeKind: 'native', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + handoffOperationId: 'attach-op-1', + unreconciled: true + }) + ) + + const evicted = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + expect(evicted.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 8, + handoffStage: null, + handoffOperationId: null + }) + expect(evicted.lease).not.toHaveProperty('settlementRetryRequired') + }) + it('latches recovery eviction from the same evicted disposition', () => { const record = agentSessionRecordFixture( agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) From 12efdefd1f95e441eaf8a34f5a7807b61e588167 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:02:53 -0700 Subject: [PATCH 007/101] fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude --- .../structured-agent-session-host.test.ts | 25 +++++++++++++++++++ .../structured-agent-session-host.ts | 13 ++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index b980c4e422cb..0deaf9d9140a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -639,6 +639,31 @@ describe('restart', () => { }) }) + it('answers the owner status of a starting chat once its start settles', async () => { + await attach() + await reboot(async () => ({ outcome: 'pid-absent' })) + await host.restoreReadableSessions() + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const settled = acquire.getMockImplementation() + if (!settled) { + throw new Error('missing acquire implementation') + } + acquire.mockImplementationOnce(async (input) => { + started.resolve() + await release.promise + return settled(input) + }) + + const hold = host.hold(SESSION, 'surface-1') + await started.promise + const status = host.handoffStatus(SESSION) + release.resolve() + await hold + + await expect(status).resolves.toMatchObject({ owner: 'native', stage: null }) + }) + it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { await attach() const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 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 8f29c6ec322b..ba508b67bcf4 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 @@ -271,11 +271,14 @@ export class StructuredAgentSessionHost { async handoffStatus(sessionId: string): Promise { this.requireSession(sessionId) - const record = this.deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return structuredAgentSessionOwnerStatus(record) + // Queued behind an in-flight attach, so a starting chat answers with its settled owner. + return this.serialize(sessionId, async () => { + const record = this.deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return structuredAgentSessionOwnerStatus(record) + }) } history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => From 42d10eee156f67334757110be850e3c35740d0c8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:22:41 -0700 Subject: [PATCH 008/101] refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude --- .../handlers/orchestration-worker-cli.test.ts | 10 +- src/cli/handlers/terminal.test.ts | 6 +- src/cli/terminal-format.ts | 7 - .../ipc/pty-agent-session-write-gate.test.ts | 290 ------------ .../pty/agent-session-write-refusal-report.ts | 20 - .../pty/ipc/write-input-chunk-yield.test.ts | 37 +- src/main/ipc/pty/ipc/write-input.ts | 62 +-- src/main/ipc/pty/runtime/controller.ts | 4 +- src/main/ipc/pty/runtime/operations.ts | 17 +- src/main/plugins/plugin-host-methods.test.ts | 25 +- .../plugins/plugin-host-service-bindings.ts | 17 +- ...gent-session-pty-write-enforcement.test.ts | 377 ---------------- .../agent-session-pty-write-gate.test.ts | 205 --------- .../runtime/agent-session-pty-write-gate.ts | 156 ------- ...h-remote-terminal-source-range-consumer.ts | 21 +- .../runtime/orca-runtime-file-commands.ts | 6 - ...rca-runtime-get-pty-record-for-pane-key.ts | 34 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 2 - ...-authoritative-terminal-wait-permission.ts | 6 +- ...runtime-write-orchestration-pointer-pty.ts | 1 - ...rca-runtime-write-terminal-agent-prompt.ts | 11 +- ...chestration-mailbox-pty-write-gate.test.ts | 116 ----- ...rchestration-structured-chat-lease.test.ts | 418 ------------------ ...dopted-structured-pointer-delivery.test.ts | 129 ------ .../mailbox-pointer-pty-write.ts | 60 +-- ...tructured-mailbox-pointer-delivery.test.ts | 45 +- .../structured-mailbox-pointer-delivery.ts | 32 +- ...tructured-session-pointer-delivery.test.ts | 80 +--- .../structured-session-pointer-delivery.ts | 58 +-- ...ty-exit-per-pty-map-reaper-ratchet.test.ts | 1 - .../worker/worker-start-receipt.ts | 11 +- .../terminal/terminal-input-delivery.ts | 6 - .../methods/terminal/terminal-send-method.ts | 13 - .../terminal-send-agent-session-lease.test.ts | 106 ----- src/main/runtime/runtime-terminal-writer.ts | 23 +- .../structured-agent-session-runtime.ts | 214 +++++---- ...ctured-agent-session-support-probe.test.ts | 10 +- src/preload/api/pty-bridge-session-control.ts | 15 +- .../agent-session-pty-write-admission.test.ts | 247 ----------- .../agent-session-pty-write-admission.ts | 177 -------- .../agent-session-pty-write-refusal-copy.ts | 13 - src/shared/pty-write-settlement.ts | 1 - src/shared/runtime-terminal-contracts.ts | 6 - 43 files changed, 165 insertions(+), 2930 deletions(-) delete mode 100644 src/main/ipc/pty-agent-session-write-gate.test.ts delete mode 100644 src/main/ipc/pty/agent-session-write-refusal-report.ts delete mode 100644 src/main/runtime/agent-session-pty-write-enforcement.test.ts delete mode 100644 src/main/runtime/agent-session-pty-write-gate.test.ts delete mode 100644 src/main/runtime/agent-session-pty-write-gate.ts delete mode 100644 src/main/runtime/orchestration-mailbox-pty-write-gate.test.ts delete mode 100644 src/main/runtime/orchestration-structured-chat-lease.test.ts delete mode 100644 src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts delete mode 100644 src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts delete mode 100644 src/shared/agent-session-pty-write-admission.test.ts delete mode 100644 src/shared/agent-session-pty-write-admission.ts delete mode 100644 src/shared/agent-session-pty-write-refusal-copy.ts diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index ce23d4221caa..b9b13d303048 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -275,15 +275,14 @@ describe('orchestration worker-start CLI contract', () => { } ) - it('prints the Structured Chat recovery action for a refused worker start', async () => { + it('prints the host error for a refused worker start', async () => { callMock.mockResolvedValue({ result: { taskId: 'task_1', dispatchId: 'ctx_1', state: 'failed', failedStage: 'dispatch_input', - lastError: - 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.', + lastError: 'terminal_not_writable', effects: [], residualResources: [] } @@ -315,10 +314,9 @@ describe('orchestration worker-start CLI contract', () => { dispatchId: 'ctx_1', state: 'failed', failedStage: 'dispatch_input', - lastError: - 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' + lastError: 'terminal_not_writable' }) - ).toMatch(/Structured Chat.*Switch it to Terminal.*orca orchestration worker-start/s) + ).toContain('terminal_not_writable') }) it('prints a reveal warning for a live background worker', async () => { diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts index 90dbc7fd4599..06df95f55a94 100644 --- a/src/cli/handlers/terminal.test.ts +++ b/src/cli/handlers/terminal.test.ts @@ -356,7 +356,7 @@ describe('terminal send CLI', () => { ]) }) - it('explains that Structured Chat blocked a refused send and how to recover', async () => { + it("reports an older host's lease refusal as a plain refused send", async () => { const call = vi.fn().mockResolvedValue({ result: { send: { @@ -390,9 +390,7 @@ describe('terminal send CLI', () => { }) expect(client.getCliStatus).toHaveBeenCalledOnce() - expect(console.log).toHaveBeenCalledWith( - expect.stringMatching(/Structured Chat.*Switch it to Terminal.*orca terminal send/s) - ) + expect(console.log).toHaveBeenCalledWith('Input refused by term-1.') expect(process.exitCode).toBe(1) }) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 06d897828fe3..97e117b2229e 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,5 +1,4 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' -import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' import { describeTerminalWaitBlockedReason } from '../shared/terminal-wait-blocked-reason-legacy-alias' import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' import type { @@ -176,12 +175,6 @@ function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string } export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { - if (result.send.agentSessionRefusal) { - const copy = structuredChatPtyWriteRefusalCopy(result.send.agentSessionRefusal, 'terminal-send') - if (copy) { - return copy - } - } if (!result.send.accepted) { const reason = result.send.refusedReason ? `: ${result.send.refusedReason}` : '' return `Input refused by ${result.send.handle}${reason}.` diff --git a/src/main/ipc/pty-agent-session-write-gate.test.ts b/src/main/ipc/pty-agent-session-write-gate.test.ts deleted file mode 100644 index 091d5eb44742..000000000000 --- a/src/main/ipc/pty-agent-session-write-gate.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - onMock: vi.fn(), - removeHandlerMock: vi.fn(), - removeAllListenersMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { - isPackaged: true, - getPath: vi.fn().mockReturnValue('/tmp/orca-test-userdata') - }, - ipcMain: { - handle: handleMock, - on: onMock, - removeHandler: removeHandlerMock, - removeAllListeners: removeAllListenersMock - }, - powerMonitor: { on: vi.fn() } -})) - -vi.mock('fs', () => ({ - existsSync: () => true, - statSync: () => ({ isDirectory: () => true, mode: 0o755 }), - accessSync: () => undefined, - mkdirSync: vi.fn(), - readFileSync: vi.fn(() => ''), - writeFileSync: vi.fn(), - chmodSync: vi.fn(), - constants: { X_OK: 1 } -})) - -vi.mock('node-pty', () => ({ - spawn: vi.fn().mockReturnValue({ - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - process: 'zsh', - pid: 12345 - }) -})) - -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } -})) - -vi.mock('../pi/titlebar-extension-service', () => ({ - piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } -})) - -import { - deletePtyOwnership, - registerPtyHandlers, - registerSshPtyProvider, - setPtyOwnership, - unregisterSshPtyProvider -} from './pty' -import { agentSessionPtyWriteGate } from '../runtime/agent-session-pty-write-gate' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import type { IPtyProvider } from '../providers/types' -import { setPtyHostBindings } from './pty-host-bindings' - -// The renderer IPC path and the runtime controller are the two byte entry points this module owns; -// both are proved to consult the lease, and both are proved to leave an unbound PTY alone. - -const CONNECTION_ID = 'conn-lease' -const PTY_ID = 'ssh:conn-lease@@pty-1' -const SESSION_ID = 'session-alpha-1' - -const handlers = new Map unknown>() -const records = new Map() - -const mainWindow = { - isDestroyed: () => false, - webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } -} -const mainWindowIpcEvent = { sender: mainWindow.webContents } - -let ptyController: { write: (ptyId: string, data: string) => boolean } | null = null - -function createMockProvider(): IPtyProvider { - return { - spawn: vi.fn().mockResolvedValue({ id: PTY_ID }), - attach: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - shutdown: vi.fn(), - sendSignal: vi.fn(), - getCwd: vi.fn(), - getInitialCwd: vi.fn(), - clearBuffer: vi.fn(), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(), - getForegroundProcess: vi.fn(), - serialize: vi.fn(), - revive: vi.fn(), - listProcesses: vi.fn(), - getDefaultShell: vi.fn(), - getProfiles: vi.fn(), - onData: vi.fn().mockReturnValue(() => {}), - onReplay: vi.fn().mockReturnValue(() => {}), - onExit: vi.fn().mockReturnValue(() => {}) - } as unknown as IPtyProvider -} - -let provider: IPtyProvider - -function publish(lease: AgentSessionLease): void { - records.set(lease.sessionId, agentSessionRecordFixture(lease)) -} - -function enforce(lease: AgentSessionLease = agentSessionLeaseFixture()): void { - publish(lease) - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) -} - -function writeFromRenderer(data: string): void { - ;(handlers.get('pty:write') as (event: unknown, args: unknown) => void)(mainWindowIpcEvent, { - id: PTY_ID, - data - }) -} - -async function writeAcceptedFromRenderer(data: string): Promise { - return await ( - handlers.get('pty:writeAccepted') as ( - event: unknown, - args: unknown - ) => boolean | Promise - )(mainWindowIpcEvent, { id: PTY_ID, data }) -} - -function lastRefusal(): { id: string; agentSessionRefusal?: { code: string } } | null { - const call = mainWindow.webContents.send.mock.calls.findLast( - ([channel]) => channel === 'pty:writeUnavailable' - ) - return (call?.[1] as { id: string; agentSessionRefusal?: { code: string } }) ?? null -} - -beforeEach(() => { - handlers.clear() - records.clear() - handleMock.mockReset() - onMock.mockReset() - mainWindow.webContents.send.mockReset() - ptyController = null - handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { - handlers.set(channel, handler) - }) - onMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { - handlers.set(channel, handler) - }) - setPtyHostBindings({ - ipc: { - handle: handleMock, - on: onMock, - removeHandler: removeHandlerMock, - removeAllListeners: removeAllListenersMock - } as never - }) - const runtime = { - setPtyController: (controller: { write: (ptyId: string, data: string) => boolean }) => { - ptyController = controller - }, - getDriver: () => ({ kind: 'desktop' }) - } - registerPtyHandlers(mainWindow as never, runtime as never) - provider = createMockProvider() - registerSshPtyProvider(CONNECTION_ID, provider) - setPtyOwnership(PTY_ID, CONNECTION_ID) -}) - -afterEach(() => { - setPtyHostBindings({}) - agentSessionPtyWriteGate.detachRecordLookup() - deletePtyOwnership(PTY_ID) - unregisterSshPtyProvider(CONNECTION_ID) -}) - -describe('renderer IPC write path', () => { - it('writes an unbound pty unchanged, which is every shell that exists today', () => { - writeFromRenderer('ls') - - expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'ls') - expect(lastRefusal()).toBeNull() - }) - - it('writes when the TUI owner holds a proven-live lease', () => { - enforce() - - writeFromRenderer('ls') - - expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'ls') - }) - - it('refuses and reports the owner when native chat holds the session', () => { - enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) - - writeFromRenderer('ls') - - expect(provider.write).not.toHaveBeenCalled() - const refusal = lastRefusal() - expect(refusal?.id).toBe(PTY_ID) - expect(refusal?.agentSessionRefusal).toMatchObject({ - code: 'agent_session_conflict', - sessionId: SESSION_ID, - ownerRuntimeKind: 'native', - ownerPid: 4242 - }) - }) - - it('refuses while the lease is unreconciled after a host restart', () => { - enforce(agentSessionLeaseFixture({ unreconciled: true })) - - writeFromRenderer('ls') - - expect(provider.write).not.toHaveBeenCalled() - expect(lastRefusal()?.agentSessionRefusal?.code).toBe('execution_owner_reconciling') - }) - - it('refuses the acknowledged write path too', async () => { - enforce(agentSessionLeaseFixture({ handoffStage: 'preparing' })) - - await expect(writeAcceptedFromRenderer('')).resolves.toBe(false) - - expect(provider.write).not.toHaveBeenCalled() - expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_conflict') - }) - - it('leaves the acknowledged write path silent for an unbound pty', async () => { - await writeAcceptedFromRenderer('') - - expect(lastRefusal()).toBeNull() - }) - - it('stops a chunked paste once the fence advances mid-flight', async () => { - enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) - vi.mocked(provider.write).mockImplementation(() => { - publish(agentSessionLeaseFixture({ runtimeFence: 8 })) - }) - - writeFromRenderer('x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)) - await vi.waitFor(() => expect(lastRefusal()).not.toBeNull()) - - expect(provider.write).toHaveBeenCalledTimes(1) - expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_checkpoint_stale') - }) - - it('lets a chunked paste finish while the same owner holds the fence', async () => { - enforce() - - writeFromRenderer('x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)) - await vi.waitFor(() => expect(provider.write).toHaveBeenCalledTimes(3)) - - expect(lastRefusal()).toBeNull() - }) -}) - -describe('runtime controller backstop', () => { - it('lets a runtime write through on an unbound pty', () => { - expect(ptyController?.write(PTY_ID, 'reply')).toBe(true) - expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'reply') - }) - - it('blocks a runtime write that never passed a typed gate', () => { - // Query replies, followups, and deliveries reach the provider through this one function. - enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) - - expect(ptyController?.write(PTY_ID, 'reply')).toBe(false) - expect(provider.write).not.toHaveBeenCalled() - expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_conflict') - }) - - it('lets a runtime write through while the TUI owner holds the lease', () => { - enforce() - - expect(ptyController?.write(PTY_ID, 'reply')).toBe(true) - expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'reply') - }) -}) diff --git a/src/main/ipc/pty/agent-session-write-refusal-report.ts b/src/main/ipc/pty/agent-session-write-refusal-report.ts deleted file mode 100644 index 488e58825e6e..000000000000 --- a/src/main/ipc/pty/agent-session-write-refusal-report.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { BrowserWindow } from 'electron' -import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' - -// Why: a lease refusal is never a silent drop — it rides the existing write-unavailable channel -// with an additive field, so old renderers keep their current behavior and new ones can name the -// owner. See docs/reference/remote-wire-compatibility.md. -export function reportAgentSessionWriteRefusal( - mainWindow: BrowserWindow, - id: string, - refusal: AgentSessionPtyWriteRefusal -): void { - if ( - mainWindow.isDestroyed() || - (typeof mainWindow.webContents.isDestroyed === 'function' && - mainWindow.webContents.isDestroyed()) - ) { - return - } - mainWindow.webContents.send('pty:writeUnavailable', { id, agentSessionRefusal: refusal }) -} diff --git a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts index 5dcb14f4516a..ceeb5127b7df 100644 --- a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts +++ b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../../shared/terminal-input' -import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate' import { ptyOwnership } from '../provider/ownership-state' import { createPtyWriteInput } from './write-input' @@ -14,7 +13,6 @@ vi.mock('../provider/registry', () => ({ })) const realSetImmediate = globalThis.setImmediate -const realReadmit = agentSessionPtyWriteGate.readmit.bind(agentSessionPtyWriteGate) const THREE_CHUNK_INPUT = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8) const mainWindow = { @@ -59,15 +57,11 @@ afterEach(() => { }) describe('chunked pty write yield', () => { - it('yields between chunks via setImmediate, not a timer, and readmits before each later chunk', async () => { + it('yields between chunks via setImmediate, not a timer', async () => { const events: string[] = [] provider.write.mockImplementation((_id: string, data: string) => { events.push(`write:${data.length}`) }) - vi.spyOn(agentSessionPtyWriteGate, 'readmit').mockImplementation((...args) => { - events.push('readmit') - return realReadmit(...args) - }) vi.spyOn(globalThis, 'setImmediate').mockImplementation(((callback: () => void) => { events.push('yield') return realSetImmediate(callback) @@ -83,10 +77,8 @@ describe('chunked pty write yield', () => { expect(events).toEqual([ `write:${TERMINAL_INPUT_CHUNK_MAX_BYTES}`, 'yield', - 'readmit', `write:${TERMINAL_INPUT_CHUNK_MAX_BYTES}`, 'yield', - 'readmit', 'write:8' ]) expect(mainWindow.webContents.send).not.toHaveBeenCalled() @@ -104,31 +96,4 @@ describe('chunked pty write yield', () => { expect(provider.write).toHaveBeenCalledTimes(1) expect(immediate).not.toHaveBeenCalled() }) - - it('stops after a yield once readmission refuses', async () => { - const readmit = vi.spyOn(agentSessionPtyWriteGate, 'readmit').mockReturnValue({ - admitted: false, - refusal: { - code: 'agent_session_checkpoint_stale', - sessionId: 'session-1', - ownerRuntimeKind: null, - handoffStage: null, - ownerPid: null, - runtimeFence: null - } - }) - - const outcome = await Promise.race([ - createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }), - afterImmediateTurns(50) - ]) - - expect(outcome).toBe(false) - expect(provider.write).toHaveBeenCalledTimes(1) - expect(readmit).toHaveBeenCalledTimes(1) - expect(mainWindow.webContents.send).toHaveBeenCalledWith( - 'pty:writeUnavailable', - expect.objectContaining({ id: PTY_ID }) - ) - }) }) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 7e42342040d7..c7348ddfaeff 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -2,15 +2,10 @@ import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } fro import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { IPtyProvider } from '../../../providers/types' import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' -import { - agentSessionPtyWriteGate, - type AgentSessionPtyWriteAdmittance -} from '../../../runtime/agent-session-pty-write-gate' import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' -import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state' @@ -57,34 +52,10 @@ export function createPtyWriteInput(deps: { mainWindow.webContents.send('pty:writeUnavailable', { id }) } - /** Single lease check for every byte-entry point this module owns. */ - const admitAgentSessionPtyWrite = (id: string): AgentSessionPtyWriteAdmittance | null => { - const admission = agentSessionPtyWriteGate.admit(id) - if (admission.admitted) { - return { sessionId: admission.sessionId, runtimeFence: admission.runtimeFence } - } - reportAgentSessionWriteRefusal(mainWindow, id, admission.refusal) - return null - } - - /** Re-check after a yield: the lease can move to another owner between chunks. */ - const readmitAgentSessionPtyWrite = ( - id: string, - admitted: AgentSessionPtyWriteAdmittance - ): boolean => { - const admission = agentSessionPtyWriteGate.readmit(id, admitted) - if (admission.admitted) { - return true - } - reportAgentSessionWriteRefusal(mainWindow, id, admission.refusal) - return false - } - const writePtyProviderInputWithinLimit = ( provider: IPtyProvider, id: string, - data: string, - admitted: AgentSessionPtyWriteAdmittance + data: string ): boolean | Promise => { const chunks = iterateTerminalInputChunks(data) const first = chunks.next() @@ -97,26 +68,25 @@ export function createPtyWriteInput(deps: { provider.write(id, first.value) return true } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value, admitted) + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) } const writePtyProviderInput = ( provider: IPtyProvider, id: string, - data: string, - admitted: AgentSessionPtyWriteAdmittance + data: string ): boolean | Promise => { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data, admitted) + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) } return tooLarge .then((result) => { - if (result || !readmitAgentSessionPtyWrite(id, admitted)) { + if (result) { return false } - return writePtyProviderInputWithinLimit(provider, id, data, admitted) + return writePtyProviderInputWithinLimit(provider, id, data) }) .catch((error) => { reportUnavailablePtyWrite(id, error) @@ -133,18 +103,12 @@ export function createPtyWriteInput(deps: { id: string, chunks: Iterator, firstChunk: string, - secondChunk: string, - admitted: AgentSessionPtyWriteAdmittance + secondChunk: string ): Promise => { try { let chunk: IteratorResult = { done: false, value: firstChunk } let nextChunk: IteratorResult = { done: false, value: secondChunk } - let first = true while (!chunk.done) { - if (!first && !readmitAgentSessionPtyWrite(id, admitted)) { - return false - } - first = false provider.write(id, chunk.value) if (!nextChunk.done) { // setImmediate, not setTimeout(0): the yield exists to let abort/data callbacks run @@ -190,10 +154,6 @@ export function createPtyWriteInput(deps: { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } - const admitted = admitAgentSessionPtyWrite(args.id) - if (!admitted) { - return false - } const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined if (!provider) { return false @@ -202,7 +162,7 @@ export function createPtyWriteInput(deps: { const now = performance.now() lastInputAtByPty.set(args.id, now) interactiveOutputCharsByPty.set(args.id, 0) - return writePtyProviderInput(provider, args.id, args.data, admitted) + return writePtyProviderInput(provider, args.id, args.data) } catch { return false } @@ -212,10 +172,6 @@ export function createPtyWriteInput(deps: { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } - const admitted = admitAgentSessionPtyWrite(args.id) - if (!admitted) { - return false - } // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. if (ptyOwnership.get(args.id) !== null) { return false @@ -228,7 +184,7 @@ export function createPtyWriteInput(deps: { const now = performance.now() lastInputAtByPty.set(args.id, now) interactiveOutputCharsByPty.set(args.id, 0) - return writePtyProviderInput(provider, args.id, args.data, admitted) + return writePtyProviderInput(provider, args.id, args.data) } catch { return false } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index a5cb3ac5ff85..8512bc7c16bc 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -44,9 +44,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(deps, ptyId, data), + write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), writeWithSettlement: (ptyId, data) => - writePtyFromRuntimeController(deps, ptyId, data, { waitForSettlement: true }), + writePtyFromRuntimeController(ptyId, data, { waitForSettlement: true }), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index c638743116a1..703fa009b7d2 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -7,38 +7,23 @@ import { rendererSerializerReadiness } from '../pane/serializer-state' import { getProviderForPty, localProvider } from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' import type { PtyRuntimeControllerDeps } from './controller-deps' -import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate' -import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' import { writeRefused, writeUnverifiable, type WriteSettlement } from '../../../../shared/pty-write-settlement' +export function writePtyFromRuntimeController(ptyId: string, data: string): boolean export function writePtyFromRuntimeController( - deps: PtyRuntimeControllerDeps, - ptyId: string, - data: string -): boolean -export function writePtyFromRuntimeController( - deps: PtyRuntimeControllerDeps, ptyId: string, data: string, options: { waitForSettlement: true } ): WriteSettlement | Promise export function writePtyFromRuntimeController( - deps: PtyRuntimeControllerDeps, ptyId: string, data: string, options?: { waitForSettlement: true } ): boolean | WriteSettlement | Promise { - // Why: the backstop for every runtime write path — query replies, followups, deliveries — - // so a caller that forgets the typed gate still cannot reach a provider. - const admission = agentSessionPtyWriteGate.admit(ptyId) - if (!admission.admitted) { - reportAgentSessionWriteRefusal(deps.mainWindow, ptyId, admission.refusal) - return options?.waitForSettlement ? writeRefused('write_gate_denied') : false - } let provider: IPtyProvider try { provider = getProviderForPty(ptyId) diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index a7a86662809f..ee6e7a5b9d02 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it, vi } from 'vitest' import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods' -import { AgentSessionPtyWriteRefusedError } from '../../shared/agent-session-pty-write-admission' function createServices(storageSet: PluginHostServices['storage']['set']): PluginHostServices { return { @@ -234,28 +233,8 @@ describe('terminal.sendText explicit worktree routing', () => { }) }) -describe('terminal.sendText under a refusing agent-session lease', () => { - it('reports who holds the session instead of an accepted-looking result', async () => { - const { delegate, services } = createTerminalHarness(['terminal:local:one']) - vi.mocked(delegate.sendTerminal).mockRejectedValue( - new AgentSessionPtyWriteRefusedError({ - code: 'agent_session_conflict', - sessionId: 'session-alpha-1', - ownerRuntimeKind: 'native', - handoffStage: null, - ownerPid: 4242, - runtimeFence: 7 - }) - ) - - const outcome = await sendTerminalText(services, 'terminal:local:one') - - expect(outcome).toMatchObject({ ok: false, code: 'action_failed' }) - expect(outcome.ok ? '' : outcome.error).toContain('session-alpha-1') - expect(outcome.ok ? '' : outcome.error).toContain('native chat') - }) - - it('sends unchanged when no lease refuses, which is every plugin send today', async () => { +describe('terminal.sendText', () => { + it('returns the runtime send acceptance', async () => { const { delegate, services } = createTerminalHarness(['terminal:local:one']) const outcome = await sendTerminalText(services, 'terminal:local:one') diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 10dd2b87e56c..266b819dbaea 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,10 +3,6 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' -import { - describeAgentSessionPtyWriteRefusal, - isAgentSessionPtyWriteRefusedError -} from '../../shared/agent-session-pty-write-admission' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -63,17 +59,8 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - try { - const result = await delegate.sendTerminal(terminalId, action) - return { accepted: result.accepted } - } catch (error) { - // Why: the plugin API carries only `accepted`, so a lease refusal would read as a silent - // drop; restate it as the message idiom plugin methods already surface to callers. - if (isAgentSessionPtyWriteRefusedError(error)) { - throw new Error(describeAgentSessionPtyWriteRefusal(error.refusal)) - } - throw error - } + const result = await delegate.sendTerminal(terminalId, action) + return { accepted: result.accepted } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), storage: { diff --git a/src/main/runtime/agent-session-pty-write-enforcement.test.ts b/src/main/runtime/agent-session-pty-write-enforcement.test.ts deleted file mode 100644 index 34a994472774..000000000000 --- a/src/main/runtime/agent-session-pty-write-enforcement.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { settledWriteStub } from '../providers/settled-pty-write-stub' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' -import { AGENT_PROMPT_SUBMIT } from '../../shared/agent-prompt-injection' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -// The runtime send paths are the choke point every RPC, plugin, and orchestration write funnels -// through, so each one is proved to consult the lease and to leave unbound PTYs untouched. - -const WORKTREE_ID = 'repo-1::/tmp/lease-worktree' -const LEAF_ID = '22222222-2222-4222-8222-222222222222' -const RUN_ID = 'run-1' -const PTY_ID = 'pty-agent-session' -const SESSION_ID = 'session-alpha-1' - -function makeStore() { - const session: WorkspaceSessionState = getDefaultWorkspaceSession() - return { - getWorkspaceSession: vi.fn(() => session), - setWorkspaceSession: vi.fn(), - getRepos: vi.fn(() => [ - { - id: 'repo-1', - path: '/tmp/lease-worktree', - displayName: 'lease', - badgeColor: '#000000', - addedAt: 0 - } - ]), - getAllWorktreeMeta: vi.fn(() => ({})), - getWorktreeMeta: vi.fn(() => undefined), - setWorktreeMeta: vi.fn(), - removeWorktreeMeta: vi.fn(), - getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), - getProjects: vi.fn(() => []) - } -} - -const records = new Map() - -function publish(lease: AgentSessionLease): void { - records.set(lease.sessionId, agentSessionRecordFixture(lease)) -} - -async function makeRuntime(options: { onWrite?: (ptyId: string, data: string) => void } = {}) { - const runtime = new OrcaRuntimeService(makeStore() as never) - const write = vi.fn((ptyId: string, data: string) => { - options.onWrite?.(ptyId, data) - // A real agent starts working when it receives the submit, and the prompt path now waits for - // that transition before it reports success. Without it every happy path here reads as stalled. - if (data === AGENT_PROMPT_SUBMIT) { - runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now()) - } - return true - }) - runtime.setPtyController({ - spawn: vi.fn(async () => ({ id: 'never' })), - write, - writeWithSettlement: settledWriteStub(write), - kill: () => true, - getForegroundProcess: async () => null, - listProcesses: vi.fn(async () => []), - hasPty: () => true - } as never) - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { - tabs: [ - { - tabId: 'tab-1', - worktreeId: WORKTREE_ID, - title: 'Agent', - activeLeafId: LEAF_ID, - layout: null - } - ], - leaves: [ - { - tabId: 'tab-1', - worktreeId: WORKTREE_ID, - leafId: LEAF_ID, - paneRuntimeId: 1, - ptyId: PTY_ID, - paneTitle: null, - title: '' - } - ] - }) - const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) - return { runtime, handle: terminals[0].handle, write } -} - -function enforce(lease: AgentSessionLease = agentSessionLeaseFixture()): void { - publish(lease) - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) -} - -afterEach(() => { - agentSessionPtyWriteGate.detachRecordLookup() - records.clear() -}) - -describe('exemption: nothing that exists today is enforced', () => { - it('sends normally when no session record is bound to the pty', async () => { - const { runtime, handle, write } = await makeRuntime() - - await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ - accepted: true - }) - - expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') - }) - - it('sends normally when the store is attached but this pty is a legacy agent terminal', async () => { - const { runtime, handle, write } = await makeRuntime() - publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - agentSessionPtyWriteGate.bindPty('some-other-pty', SESSION_ID) - - await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ - accepted: true - }) - - expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') - }) - - it('sends agent prompts normally on an unbound pty', async () => { - const { runtime, handle, write } = await makeRuntime() - - await expect(runtime.sendTerminalAgentPrompt(handle, 'go')).resolves.toBeDefined() - - expect(write).toHaveBeenCalled() - }) -}) - -describe('terminal.send path', () => { - it('writes when the TUI owner holds a proven-live lease', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce() - - await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ - accepted: true - }) - - expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') - }) - - it('refuses and writes nothing when native chat owns the session', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) - - await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( - 'agent_session_conflict' - ) - - expect(write).not.toHaveBeenCalled() - }) - - it('refuses while a handoff is in flight', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce(agentSessionLeaseFixture({ handoffStage: 'new-owner-proving' })) - - await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( - 'agent_session_conflict' - ) - - expect(write).not.toHaveBeenCalled() - }) - - it('refuses while the lease is unreconciled after a host restart', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce(agentSessionLeaseFixture({ unreconciled: true })) - - await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( - 'execution_owner_reconciling' - ) - - expect(write).not.toHaveBeenCalled() - }) - - it('refuses an interrupt, which carries no text of its own', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) - - await expect(runtime.sendTerminal(handle, { interrupt: true })).rejects.toThrow( - 'agent_session_conflict' - ) - - expect(write).not.toHaveBeenCalled() - }) - - it('refuses before the mobile floor is reserved', async () => { - const { runtime, handle } = await makeRuntime() - enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) - const reserveWrite = vi.fn() - - await expect(runtime.sendTerminal(handle, { text: 'ls' }, { reserveWrite })).rejects.toThrow() - - expect(reserveWrite).not.toHaveBeenCalled() - }) - - it('refuses when an async send guard outlives the admitted fence', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) - - await expect( - runtime.sendTerminal( - handle, - { text: 'ls' }, - { - beforeWrite: async () => { - publish(agentSessionLeaseFixture({ runtimeFence: 8 })) - await Promise.resolve() - } - } - ) - ).rejects.toThrow('agent_session_checkpoint_stale') - - expect(write).not.toHaveBeenCalled() - }) -}) - -describe('agent prompt path', () => { - it('refuses the whole paste when another runtime owns the session', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) - - await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( - 'agent_session_conflict' - ) - - expect(write).not.toHaveBeenCalled() - }) - - it('writes the prompt when the TUI owner still holds the lease', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce() - - await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).resolves.toBeDefined() - - expect(write).toHaveBeenCalled() - }) - - it('does not terminate a partial paste after its admitted fence moved', async () => { - const { runtime, handle, write } = await makeRuntime({ - onWrite: () => publish(agentSessionLeaseFixture({ runtimeFence: 8 })) - }) - enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) - - await expect( - runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2)) - ).rejects.toThrow('agent_session_checkpoint_stale') - - expect(write).toHaveBeenCalledTimes(1) - }) -}) - -describe('lease transition against an in-flight write', () => { - const CHUNKED_TEXT = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8) - - it('stops a paste mid-flight once the fence advances under it', async () => { - let written = 0 - const { runtime, handle, write } = await makeRuntime({ - onWrite: () => { - written += 1 - if (written === 1) { - // A handoff completed between the first and second chunk. - publish(agentSessionLeaseFixture({ runtimeFence: 8 })) - } - } - }) - enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) - - await expect(runtime.sendTerminal(handle, { text: CHUNKED_TEXT })).rejects.toThrow( - 'agent_session_checkpoint_stale' - ) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('lets a paste finish while the same owner holds the fence', async () => { - const { runtime, handle, write } = await makeRuntime() - enforce() - - await expect(runtime.sendTerminal(handle, { text: CHUNKED_TEXT })).resolves.toMatchObject({ - accepted: true - }) - - expect(write).toHaveBeenCalledTimes(3) - }) - - it('fences preview paste chunks to the lease admitted before the first chunk', async () => { - let written = 0 - const { runtime, write } = await makeRuntime({ - onWrite: () => { - written += 1 - if (written === 1) { - publish(agentSessionLeaseFixture({ runtimeFence: 8 })) - } - } - }) - enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) - - await expect(runtime.writeTerminalPreviewInput(PTY_ID, CHUNKED_TEXT)).resolves.toBe(false) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('withholds the submit when the lease moves during the text/suffix pause', async () => { - const { runtime, handle, write } = await makeRuntime({ - onWrite: (_ptyId, data) => { - if (data === 'ls') { - publish(agentSessionLeaseFixture({ runtimeFence: 8 })) - } - } - }) - enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) - - await expect(runtime.sendTerminal(handle, { text: 'ls', enter: true })).rejects.toThrow( - 'agent_session_checkpoint_stale' - ) - - expect(write).toHaveBeenCalledTimes(1) - expect(write).not.toHaveBeenCalledWith(PTY_ID, '\r') - }) - - it('withholds orchestration Enter after the pointer lease fence moves', async () => { - vi.useFakeTimers() - try { - const { runtime, handle, write } = await makeRuntime({ - onWrite: (_ptyId, data) => { - if (data !== '\r') { - publish(agentSessionLeaseFixture({ runtimeFence: 8 })) - } - } - }) - let messages: { id: string; sequence: number; type: string }[] = [] - const getPendingMailboxPointerMessages = vi.fn(() => []) - // Pointer delivery reads pending reservations before staging unread mail; omitting - // either side makes the fence assertion vacuous because nothing reaches the PTY. - runtime.setOrchestrationDb({ - getUndeliveredUnreadMessages: () => messages, - getPendingMailboxPointerMessages, - areUnreadMessages: () => true, - stageMailboxPointerEnter: () => true, - markMailboxPointerWriteAttempted: () => true, - markMailboxPointerEnterAttempted: () => true, - settleMailboxPointerEnter: () => undefined, - getCurrentRunForPane: () => ({ id: RUN_ID }), - getRun: () => ({ id: RUN_ID, coordinator_handle: handle }) - } as never) - runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1) - runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2) - getPendingMailboxPointerMessages.mockClear() - enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) - messages = [{ id: 'msg-1', sequence: 1, type: 'status' }] - - runtime.deliverPendingMessagesForHandle(`run:${RUN_ID}`) - expect(getPendingMailboxPointerMessages).toHaveBeenCalledWith(`run:${RUN_ID}`) - expect(write).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(500) - - expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/main/runtime/agent-session-pty-write-gate.test.ts b/src/main/runtime/agent-session-pty-write-gate.test.ts deleted file mode 100644 index 7c339131a6be..000000000000 --- a/src/main/runtime/agent-session-pty-write-gate.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionPtyWriteGate } from './agent-session-pty-write-gate' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { isAgentSessionPtyWriteRefusedError } from '../../shared/agent-session-pty-write-admission' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' - -const PTY_ID = 'pty-1' -const SESSION_ID = 'session-alpha-1' - -let gate: AgentSessionPtyWriteGate -let records: Map - -function publish(lease: AgentSessionLease): void { - records.set(lease.sessionId, agentSessionRecordFixture(lease)) -} - -beforeEach(() => { - gate = new AgentSessionPtyWriteGate() - records = new Map() -}) - -describe('capability invisibility', () => { - it('admits every PTY while nothing is bound, which is the shape of today builds', () => { - gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - expect(gate.enforcing).toBe(false) - expect(gate.admit('any-shell')).toEqual({ - admitted: true, - sessionId: null, - runtimeFence: null - }) - }) - - it('admits an unbound PTY even once another PTY is bound and refusing', () => { - gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) - gate.bindPty(PTY_ID, SESSION_ID) - expect(gate.admit(PTY_ID).admitted).toBe(false) - expect(gate.admit('ordinary-shell').admitted).toBe(true) - }) - - it('admits a bound PTY while no store is attached, so a half-wired host cannot refuse', () => { - gate.bindPty(PTY_ID, SESSION_ID) - expect(gate.enforcing).toBe(false) - expect(gate.admit(PTY_ID).admitted).toBe(true) - }) -}) - -describe('binding lifecycle', () => { - beforeEach(() => { - gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - }) - - it('reports the session a PTY is bound to', () => { - gate.bindPty(PTY_ID, SESSION_ID) - expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) - expect(gate.boundSessionId('other')).toBeNull() - }) - - it('returns an unbound PTY to the exempt path when it is unbound', () => { - publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) - gate.bindPty(PTY_ID, SESSION_ID) - expect(gate.admit(PTY_ID).admitted).toBe(false) - gate.unbindPty(PTY_ID) - expect(gate.admit(PTY_ID).admitted).toBe(true) - }) - - it('drops every binding when the store detaches', () => { - publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) - gate.bindPty(PTY_ID, SESSION_ID) - gate.detachRecordLookup() - expect(gate.enforcing).toBe(false) - expect(gate.admit(PTY_ID).admitted).toBe(true) - }) -}) - -describe('overlapping adoption attempts on one pane', () => { - beforeEach(() => { - gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - }) - - it('keeps the newer attempt bound when the one it superseded gives up', () => { - gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') - expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-b')).toBe(true) - - // Both attempts carry the same session, so only the spawn token can tell this release apart. - expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) - expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) - expect(gate.releasePtyAttempt(PTY_ID, 'spawn-b')).toBe(true) - expect(gate.boundSessionId(PTY_ID)).toBeNull() - }) - - it('leaves a settled owner pane alone when a later attempt fails', () => { - gate.bindPty(PTY_ID, SESSION_ID) - - expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-late')).toBe(false) - expect(gate.releasePtyAttempt(PTY_ID, 'spawn-late')).toBe(false) - expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) - }) - - it('settles a proven attempt so no later attempt can release its pane', () => { - gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') - expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(true) - - expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) - expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) - expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) - }) - - it('settles nothing once the pane is gone', () => { - gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') - gate.unbindPty(PTY_ID) - - expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) - expect(gate.boundSessionId(PTY_ID)).toBeNull() - }) -}) - -describe('admission through the store', () => { - beforeEach(() => { - gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) - gate.bindPty(PTY_ID, SESSION_ID) - }) - - it('admits a proven-live TUI owner and reports the fence it was admitted under', () => { - publish(agentSessionLeaseFixture({ runtimeFence: 11 })) - expect(gate.admit(PTY_ID)).toEqual({ - admitted: true, - sessionId: SESSION_ID, - runtimeFence: 11 - }) - }) - - it.each(['preparing', 'old-owner-stopped', 'new-owner-proving'] as const)( - 'refuses TUI writes while handoff stage %s is active', - (handoffStage) => { - publish(agentSessionLeaseFixture({ runtimeKind: 'tui', handoffStage })) - const admission = gate.admit(PTY_ID) - expect(admission.admitted).toBe(false) - if (!admission.admitted) { - expect(admission.refusal).toMatchObject({ - code: 'agent_session_conflict', - handoffStage, - ownerRuntimeKind: 'tui' - }) - } - } - ) - - it('refuses when the record vanished from the store', () => { - const admission = gate.admit(PTY_ID) - expect(admission.admitted).toBe(false) - }) - - it('throws the typed refusal from assertAdmitted', () => { - publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) - let thrown: unknown = null - try { - gate.assertAdmitted(PTY_ID) - } catch (error) { - thrown = error - } - expect(isAgentSessionPtyWriteRefusedError(thrown)).toBe(true) - if (!isAgentSessionPtyWriteRefusedError(thrown)) { - return - } - expect(thrown.refusal.code).toBe('agent_session_conflict') - expect(thrown.refusal.ownerRuntimeKind).toBe('native') - }) - - it('returns the admittance from assertAdmitted so later chunks can be fenced', () => { - publish(agentSessionLeaseFixture({ runtimeFence: 3 })) - expect(gate.assertAdmitted(PTY_ID)).toEqual({ sessionId: SESSION_ID, runtimeFence: 3 }) - }) - - it('throws from assertReadmitted once the lease moved under an in-flight write', () => { - publish(agentSessionLeaseFixture({ runtimeFence: 3 })) - const admitted = gate.assertAdmitted(PTY_ID) - publish(agentSessionLeaseFixture({ runtimeFence: 4 })) - expect(() => gate.assertReadmitted(PTY_ID, admitted)).toThrowError( - 'agent_session_checkpoint_stale' - ) - }) - - it('lets an in-flight write finish while the lease is unchanged', () => { - publish(agentSessionLeaseFixture({ runtimeFence: 3 })) - const admitted = gate.assertAdmitted(PTY_ID) - expect(() => gate.assertReadmitted(PTY_ID, admitted)).not.toThrow() - }) - - it('leaves an in-flight write on an exempt PTY alone', () => { - const admitted = gate.assertAdmitted('ordinary-shell') - expect(admitted).toEqual({ sessionId: null, runtimeFence: null }) - expect(() => gate.assertReadmitted('ordinary-shell', admitted)).not.toThrow() - }) - - it('refuses an exempt write that acquired a refusing binding mid-flight', () => { - const admitted = gate.assertAdmitted('late-bound') - publish(agentSessionLeaseFixture({ sessionId: 'session-beta-2', runtimeKind: 'native' })) - gate.bindPty('late-bound', 'session-beta-2') - expect(() => gate.assertReadmitted('late-bound', admitted)).toThrow() - }) -}) diff --git a/src/main/runtime/agent-session-pty-write-gate.ts b/src/main/runtime/agent-session-pty-write-gate.ts deleted file mode 100644 index 662016ad6ef4..000000000000 --- a/src/main/runtime/agent-session-pty-write-gate.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Host-side registry that maps a live PTY to the durable agent session it belongs to, and answers - * whether bytes may enter that PTY right now. - * - * It lives on the execution host that owns the process, never in a client: a paired desktop, a - * mobile client, and an SSH-attached Orca all reach the same gate through the host that spawned - * the PTY. With nothing bound — the state of every build until a later part registers records — - * `admit` short-circuits to admitted, so no existing terminal path changes behavior or cost. - */ - -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { - AgentSessionPtyWriteRefusedError, - evaluateAgentSessionPtyWriteAdmission, - reevaluateAgentSessionPtyWriteAdmission, - type AgentSessionPtyBinding, - type AgentSessionPtyWriteAdmission -} from '../../shared/agent-session-pty-write-admission' - -export type AgentSessionRecordLookup = (sessionId: string) => AgentSessionRecord | null - -/** What an admitted write carries forward so its later chunks can be fenced against the same lease. */ -export type AgentSessionPtyWriteAdmittance = { - sessionId: string | null - runtimeFence: number | null -} - -const ADMITTED_UNBOUND: AgentSessionPtyWriteAdmission = { - admitted: true, - sessionId: null, - runtimeFence: null -} - -/** - * A pane binding, plus the adoption attempt that is still proving it. - * - * `attemptToken` is null once an owner is proven — a settled binding no attempt may take away. - * While it is non-null the binding belongs to that one in-flight attempt, which is the only thing - * that tells two overlapping adoptions apart: both carry the same sessionId. - */ -type BoundPane = { sessionId: string; attemptToken: string | null } - -export class AgentSessionPtyWriteGate { - private readonly panesByPtyId = new Map() - private lookup: AgentSessionRecordLookup | null = null - - /** Point the gate at the durable store. Until this is called nothing can be enforced. */ - attachRecordLookup(lookup: AgentSessionRecordLookup): void { - this.lookup = lookup - } - - detachRecordLookup(): void { - this.lookup = null - this.panesByPtyId.clear() - } - - bindPty(ptyId: string, sessionId: string): void { - this.panesByPtyId.set(ptyId, { sessionId, attemptToken: null }) - } - - unbindPty(ptyId: string): void { - this.panesByPtyId.delete(ptyId) - } - - /** - * Claim the pane for one adoption attempt, identified by the spawn token its reservation minted. - * A settled owner keeps the pane; another attempt's claim is superseded, because the newest - * reservation is the one the record now names. Callers must already have refused a pane bound to - * a different session. - */ - bindPtyForAttempt(ptyId: string, sessionId: string, attemptToken: string): boolean { - const current = this.panesByPtyId.get(ptyId) - if (current && current.attemptToken === null) { - return false - } - this.panesByPtyId.set(ptyId, { sessionId, attemptToken }) - return true - } - - /** Promote this attempt's claim to a settled binding once its owner is proven. */ - settlePtyAttempt(ptyId: string, attemptToken: string): boolean { - const current = this.panesByPtyId.get(ptyId) - if (current?.attemptToken !== attemptToken) { - return false - } - this.panesByPtyId.set(ptyId, { sessionId: current.sessionId, attemptToken: null }) - return true - } - - /** - * Compare-and-clear: hand the pane back only while this attempt still holds it. A losing attempt - * that unbinds by pty alone rips the pane out from under the attempt that superseded it, and then - * that one's proof is refused too — both fail where one should have won. - */ - releasePtyAttempt(ptyId: string, attemptToken: string): boolean { - if (this.panesByPtyId.get(ptyId)?.attemptToken !== attemptToken) { - return false - } - this.panesByPtyId.delete(ptyId) - return true - } - - boundSessionId(ptyId: string): string | null { - return this.panesByPtyId.get(ptyId)?.sessionId ?? null - } - - /** False while no PTY is bound, which is every write path in today's builds. */ - get enforcing(): boolean { - return this.lookup !== null && this.panesByPtyId.size > 0 - } - - admit(ptyId: string): AgentSessionPtyWriteAdmission { - if (!this.enforcing) { - return ADMITTED_UNBOUND - } - return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId)) - } - - /** Re-check a write already in flight against the fence it was admitted under. */ - readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission { - if (admitted.sessionId === null && !this.enforcing) { - return ADMITTED_UNBOUND - } - return reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: this.binding(ptyId) }) - } - - /** Admit or throw the typed refusal. Used where the caller already reports errors to a client. */ - assertAdmitted(ptyId: string): AgentSessionPtyWriteAdmittance { - const admission = this.admit(ptyId) - if (!admission.admitted) { - throw new AgentSessionPtyWriteRefusedError(admission.refusal) - } - return { sessionId: admission.sessionId, runtimeFence: admission.runtimeFence } - } - - assertReadmitted(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): void { - const admission = this.readmit(ptyId, admitted) - if (!admission.admitted) { - throw new AgentSessionPtyWriteRefusedError(admission.refusal) - } - } - - private binding(ptyId: string): AgentSessionPtyBinding | null { - const pane = this.panesByPtyId.get(ptyId) - if (pane === undefined) { - return null - } - return { sessionId: pane.sessionId, record: this.lookup?.(pane.sessionId) ?? null } - } -} - -/** - * One gate per host process. Both the runtime's send paths and the PTY IPC layer consult this same - * instance, so a write cannot reach a provider by entering through the other door. - */ -export const agentSessionPtyWriteGate = new AgentSessionPtyWriteGate() diff --git a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts index 68ebabb8857a..f433c75793db 100644 --- a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts +++ b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts @@ -12,7 +12,6 @@ import { notifyRuntimeListeners } from './runtime-async-boundaries' import type { RuntimeTerminalBufferSnapshot } from './runtime-terminal-state-records' import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS } from './orca-runtime-postlude' import { assertTerminalInputWithinLimitWithYield } from './terminal-send-payload' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends OrcaRuntimeWithRecordAgentPromptLifecycleState { attachRemoteTerminalSourceRangeConsumer( @@ -182,20 +181,14 @@ export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends Orca } try { await assertTerminalInputWithinLimitWithYield(data) - const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) - await this.writeTerminalInputChunks( - ptyId, - data, - { - // Why: a phone can claim the floor while a paste yields between chunks. - beforeWrite: () => { - if (this.getDriver(ptyId).kind === 'mobile') { - throw new Error('terminal_mobile_driver_active') - } + await this.writeTerminalInputChunks(ptyId, data, { + // Why: a phone can claim the floor while a paste yields between chunks. + beforeWrite: () => { + if (this.getDriver(ptyId).kind === 'mobile') { + throw new Error('terminal_mobile_driver_active') } - }, - admitted - ) + } + }) return true } catch { return false diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index 679728ca1f0b..56721971132a 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -17,7 +17,6 @@ import { RuntimeGitHubIssueCommentCommands } from './runtime-github-issue-commen import { RuntimeGitHubProjectCommands } from './runtime-github-project-commands' import { RuntimeRepositoryHooksCommands } from './runtime-repository-hooks-commands' import { RuntimeRepositoryIssueCommand } from './runtime-repository-issue-command' -import type { AgentSessionPtyWriteAdmittance } from './agent-session-pty-write-gate' import { ClientHostedBrowserRowPublisher } from './client-hosted-browser-row-publication' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { getBrowserHostLeaseRegistry } from './browser-host-lease-registry-instance' @@ -204,11 +203,6 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC getLocalGitArgs: (repo) => this.getLocalGitExecutionOptionArgs(repo) }) - protected readonly orchestrationPointerAdmissionByPtyId = new Map< - string, - AgentSessionPtyWriteAdmittance - >() - protected readonly clientHostedBrowserRows = new ClientHostedBrowserRowPublisher({ listClientPages: (worktreeId) => getRuntimeBrowserPageRegistry(this).listPages(worktreeId), hasLivePlacement: (browserPageId) => diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 27012bdf8e9e..564555513496 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -4,10 +4,8 @@ import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-term import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { detectAgentStatusFromTitle, isClaudeManagementTitle } from '../../shared/agent-detection' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { structuredWorkerIdentities } from './structured-worker-identity' -import { isSettledNativeOwner } from './orchestration/structured-session-pointer-delivery' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' import { resolveTerminalIdentityFromProbes, @@ -216,10 +214,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return null } const identity = resolveStructuredWorkerAuthority(assignee, this._orchestrationDb)?.identity - if (identity) { - return { sessionId: identity.sessionId, dispatchId } - } - return this.resolveAdoptedStructuredMailboxTarget(assignee, dispatchId) + return identity ? { sessionId: identity.sessionId, dispatchId } : null } /** @@ -267,33 +262,6 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return { sessionId: identity.sessionId, dispatchId } } - /** - * A PTY-born worker whose pane was since adopted by native chat. - * - * Its bytes cannot land — every runtime write path re-admits through the same gate — so the - * pointer has to travel as a session turn instead. Only a SETTLED native owner qualifies: a - * mid-handoff lease may become a TUI again, and redirecting there races the takeover. - */ - protected resolveAdoptedStructuredMailboxTarget( - assignee: string, - dispatchId: string - ): StructuredPointerTarget | null { - let ptyId: string | null | undefined - try { - ptyId = this.getLiveLeafForHandle(assignee).leaf.ptyId - } catch { - return null - } - if (!ptyId) { - return null - } - const admission = agentSessionPtyWriteGate.admit(ptyId) - if (admission.admitted || !isSettledNativeOwner(admission.refusal)) { - return null - } - return { sessionId: admission.refusal.sessionId, dispatchId, refusal: admission.refusal } - } - protected scheduleRestoredMessageRepoints(): void { let handles: Set try { diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 659ee4392949..588a834d6e64 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -7,7 +7,6 @@ import { resolveUnreportedExitCause } from '../../shared/terminal-exit-cause' import { SSH_EXIT_UNCONFIRMED_REASON } from '../../shared/pty-liveness-verdict' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import type { RetiredTerminalSurface } from './mobile-session-terminal-retirement' import { parsePaneKey } from '../../shared/stable-pane-id' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' @@ -120,7 +119,6 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.intentionalHandlelessPtyStops.has(ptyId) && (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) advertisedUrlWatcher.unbindPty(ptyId) - agentSessionPtyWriteGate.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.terminalViewSubscribers.clearSubscribers(ptyId) diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index efd0f5cb1c93..ad7c81f2e872 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -9,7 +9,6 @@ import type { AgentStatusEntry } from '../../shared/agent-status-types' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' -import type { AgentSessionPtyWriteAdmittance } from './agent-session-pty-write-gate' import { getRegisteredSshState } from '../ssh/ssh-target-registry' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' @@ -70,10 +69,9 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O protected writeTerminalInputChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {}, - admitted?: AgentSessionPtyWriteAdmittance + options: RuntimeTerminalWriteOptions = {} ): Promise { - return this.terminalWriter.writeChunks(ptyId, text, options, admitted) + return this.terminalWriter.writeChunks(ptyId, text, options) } /** Platform of the host whose pty transport ingests our writes -- deliberately NOT the OS diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index b3eeeee29654..538ab4d6d4d0 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -28,7 +28,6 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith return writeOrchestrationPointerWithSettlement({ ptyId, data, - admissionByPtyId: this.orchestrationPointerAdmissionByPtyId, controller: this.ptyController }) } diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index b5aff5f755c3..5e90602755cb 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -7,7 +7,6 @@ import { waitForAgentPromptDelay, waitForAgentPromptPromise } from './orca-runtime-core' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { AGENT_PROMPT_SUBMIT, agentPromptSubmitJoinsPasteFrame, @@ -34,7 +33,6 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) - const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) const writeHostPlatform = this.getPtyWriteHostPlatform(ptyId) const pty = this.ptysById.get(ptyId) // OMP treats a large bracketed paste as a menu unless submit arrives in the same PTY write. @@ -60,7 +58,6 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso permissionBaseline, this.getAgentPromptActivity(handle, ptyId) ) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) // Keep the bracketed paste frame in one PTY write; Claude's composer can drop the // beginning when a large frame is split into independently processed chunks. renderGate?.arm() @@ -85,17 +82,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } else { const agent = this.getPtyAgent(ptyId) const submitDelayMs = options.promptForSchedule - ? resolveAgentPromptSubmitDelayForAgent( - writeHostPlatform, - options.promptForSchedule, - agent - ) + ? resolveAgentPromptSubmitDelayForAgent(writeHostPlatform, options.promptForSchedule, agent) : getAgentPromptSubmitDelayMs(writeHostPlatform, pasteByteLength) await waitForAgentPromptDelay(submitDelayMs, options.signal) } assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) if (!submitWithPaste) { try { await options.beforeWrite?.(ptyId) @@ -110,7 +102,6 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) if (!submitWithPaste) { if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') diff --git a/src/main/runtime/orchestration-mailbox-pty-write-gate.test.ts b/src/main/runtime/orchestration-mailbox-pty-write-gate.test.ts deleted file mode 100644 index 117144823cc9..000000000000 --- a/src/main/runtime/orchestration-mailbox-pty-write-gate.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { writeRefused, type WriteSettlement } from '../../shared/pty-write-settlement' -import { rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' -import { - createBoundRun, - createDatabase, - createRuntime, - driveToLiveIdle, - insertDirectRunMessage, - pointerCount, - PTY_ID, - temporaryDirectories -} from './orchestration-mailbox-notification-test-harness' - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, - BrowserWindow: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - webContents: { fromId: vi.fn(() => null) } -})) - -function writePointer( - runtime: unknown, - ptyId: string, - data: string -): WriteSettlement | Promise { - return ( - runtime as { - writeOrchestrationPointerPty: ( - ptyId: string, - data: string - ) => WriteSettlement | Promise - } - ).writeOrchestrationPointerPty.call(runtime, ptyId, data) -} - -describe('orchestration mailbox PTY write gate', () => { - afterEach(() => { - vi.useRealTimers() - agentSessionPtyWriteGate.detachRecordLookup() - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } - }) - - it('withholds pointer and Enter bytes from a bound lease the write gate refuses', async () => { - vi.useFakeTimers() - const db = createDatabase('orca-mailbox-pty-write-gate-refused-') - const harness = createRuntime(db) - const run = createBoundRun(db, 'Refused structured session') - insertDirectRunMessage(db, run.id, 'Do not write into native chat') - const lease = agentSessionLeaseFixture({ runtimeKind: 'native' }) - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => - sessionId === lease.sessionId ? agentSessionRecordFixture(lease) : null - ) - agentSessionPtyWriteGate.bindPty(PTY_ID, lease.sessionId) - - await driveToLiveIdle(harness.runtime) - - expect(pointerCount(harness.write)).toBe(0) - expect(await writePointer(harness.runtime, PTY_ID, 'orchestration check')).toEqual( - writeRefused('write_gate_denied') - ) - expect(await writePointer(harness.runtime, PTY_ID, '\r')).toEqual( - writeRefused('write_gate_denied') - ) - expect(harness.write).not.toHaveBeenCalled() - db.close() - }) - - it('keeps an explicitly unbound legacy terminal on the pointer write path', async () => { - vi.useFakeTimers() - const db = createDatabase('orca-mailbox-pty-write-gate-unbound-') - const harness = createRuntime(db) - const run = createBoundRun(db, 'Legacy terminal mailbox') - insertDirectRunMessage(db, run.id, 'Legacy pointer') - const lease = agentSessionLeaseFixture({ runtimeKind: 'native' }) - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => - sessionId === lease.sessionId ? agentSessionRecordFixture(lease) : null - ) - agentSessionPtyWriteGate.bindPty('another-pty', lease.sessionId) - - await driveToLiveIdle(harness.runtime) - - expect(pointerCount(harness.write)).toBe(1) - await vi.advanceTimersByTimeAsync(500) - expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(1) - db.close() - }) - - it('keeps mailbox pointer delivery working for an admitted bound TUI lease', async () => { - vi.useFakeTimers() - const db = createDatabase('orca-mailbox-pty-write-gate-admitted-') - const harness = createRuntime(db) - const run = createBoundRun(db, 'Admitted structured session') - insertDirectRunMessage(db, run.id, 'Admitted pointer') - const lease = agentSessionLeaseFixture() - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => - sessionId === lease.sessionId ? agentSessionRecordFixture(lease) : null - ) - agentSessionPtyWriteGate.bindPty(PTY_ID, lease.sessionId) - - await driveToLiveIdle(harness.runtime) - expect(pointerCount(harness.write)).toBe(1) - await vi.advanceTimersByTimeAsync(500) - - expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(1) - db.close() - }) -}) diff --git a/src/main/runtime/orchestration-structured-chat-lease.test.ts b/src/main/runtime/orchestration-structured-chat-lease.test.ts deleted file mode 100644 index c4b528d1d9bc..000000000000 --- a/src/main/runtime/orchestration-structured-chat-lease.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { settledWriteStub } from '../providers/settled-pty-write-stub' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' -import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { OrcaRuntimeService } from './orca-runtime' -import { OrchestrationDb } from './orchestration/db' -import { RpcDispatcher } from './rpc/dispatcher' -import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' -import { TERMINAL_METHODS } from './rpc/methods/terminal' - -const WORKTREE_ID = 'repo-structured-chat::/tmp/structured-chat' -const SESSION_ID = 'session-structured-chat' -const COORDINATOR = { - handle: 'term_structured_coord', - tabId: '11111111-1111-4111-8111-111111111111', - leafId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - ptyId: 'pty-structured-coord' -} -const WORKER = { - handle: 'term_structured_worker', - tabId: '22222222-2222-4222-8222-222222222222', - leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - ptyId: 'pty-structured-worker' -} -const PLAIN = { - handle: 'term-plain', - tabId: '33333333-3333-4333-8333-333333333333', - leafId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - ptyId: 'pty-plain' -} -const LOCATION: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKTREE_ID, - workspaceKind: 'git-worktree' -} - -type TestTerminal = typeof COORDINATOR - -function paneKey(terminal: TestTerminal): string { - return `${terminal.tabId}:${terminal.leafId}` -} - -function makeStore() { - const session = getDefaultWorkspaceSession() - const repo = { - id: 'repo-structured-chat', - path: '/tmp/structured-chat', - displayName: 'structured-chat', - badgeColor: '#000000', - addedAt: 0 - } - return { - getWorkspaceSession: vi.fn(() => session), - setWorkspaceSession: vi.fn(), - getRepos: vi.fn(() => [repo]), - getRepo: vi.fn(() => repo), - getAllWorktreeMeta: vi.fn(() => ({})), - getWorktreeMeta: vi.fn(() => undefined), - setWorktreeMeta: vi.fn(), - removeWorktreeMeta: vi.fn(), - getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), - getProjects: vi.fn(() => []) - } -} - -describe('orchestration while Structured Chat owns an agent session', () => { - let directory: string - let recordStore: AgentSessionRecordStore - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let dispatcher: RpcDispatcher - let writes: Mock<(ptyId: string, data: string) => void> - let operationSequence: number - - beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-orchestration-structured-chat-')) - recordStore = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - db = new OrchestrationDb(':memory:') - runtime = new OrcaRuntimeService(makeStore() as never) - runtime.setOrchestrationDb(db) - vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ - id: WORKTREE_ID, - repoId: 'repo-structured-chat' - } as never) - writes = vi.fn<(ptyId: string, data: string) => void>() - const admittedWrite = (ptyId: string, data: string): boolean => { - agentSessionPtyWriteGate.assertAdmitted(ptyId) - writes(ptyId, data) - return true - } - runtime.setPtyController({ - spawn: vi.fn(async () => ({ id: 'unused' })), - write: admittedWrite, - writeWithSettlement: settledWriteStub(admittedWrite), - kill: vi.fn(() => true), - getForegroundProcess: vi.fn(async () => 'codex'), - listProcesses: vi.fn(async () => []), - hasPty: vi.fn(() => true) - } as never) - for (const terminal of [COORDINATOR, WORKER, PLAIN]) { - runtime.registerPty(terminal.ptyId, WORKTREE_ID, null, { - tabId: terminal.tabId, - leafId: terminal.leafId, - incarnationId: `${terminal.ptyId}-incarnation`, - agentLaunchAuthority: { launchToken: `${terminal.ptyId}-launch`, launchAgent: 'codex' } - }) - runtime.registerPreAllocatedHandleForPty(terminal.ptyId, terminal.handle) - } - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { - tabs: [COORDINATOR, WORKER, PLAIN].map((terminal) => ({ - tabId: terminal.tabId, - worktreeId: WORKTREE_ID, - title: 'Codex', - activeLeafId: terminal.leafId, - layout: null - })), - leaves: [COORDINATOR, WORKER, PLAIN].map((terminal, index) => ({ - tabId: terminal.tabId, - worktreeId: WORKTREE_ID, - leafId: terminal.leafId, - paneRuntimeId: index + 1, - ptyId: terminal.ptyId, - paneTitle: null, - title: 'Codex' - })) - }) - await runtime.listTerminals() - for (const terminal of [COORDINATOR, WORKER, PLAIN]) { - runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex working\x07', 1) - runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex done\x07', 2) - } - operationSequence = 0 - await establishOwner('native', 'spawn-native', null) - agentSessionPtyWriteGate.attachRecordLookup((sessionId) => recordStore.getRecord(sessionId)) - agentSessionPtyWriteGate.bindPty(WORKER.ptyId, SESSION_ID) - dispatcher = new RpcDispatcher({ - runtime, - methods: [...ORCHESTRATION_METHODS, ...TERMINAL_METHODS] - }) - }) - - afterEach(async () => { - vi.useRealTimers() - agentSessionPtyWriteGate.detachRecordLookup() - db.close() - await rm(directory, { recursive: true, force: true }) - }) - - function operation() { - operationSequence += 1 - return { - callerKey: 'structured-chat-test', - operationId: `1800000000000-${operationSequence.toString(16).padStart(32, '0')}`, - fingerprint: `structured-chat-${operationSequence}` - } - } - - async function establishOwner( - runtimeKind: 'native' | 'tui', - spawnToken: string, - expectedFence: number | null - ): Promise { - const now = 1_800_000_000_000 + operationSequence - const reserved = await recordStore.reserveOwner({ - sessionId: SESSION_ID, - location: LOCATION, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - runtimeKind, - expectedFence, - spawnToken, - claimKeyId: 'key-1', - handoffOperationId: null, - probe: - expectedFence === null - ? { outcome: 'indeterminate', reason: 'new session' } - : { outcome: 'pid-absent' }, - operation: operation(), - now - }) - const fence = reserved.record.lease.runtimeFence - await recordStore.commitProcessIdentity({ - sessionId: SESSION_ID, - fence, - process: { hostId: 'local', pid: 4242 + fence, processStartTimeMs: now, spawnToken }, - now - }) - await recordStore.proveOwner({ - sessionId: SESSION_ID, - fence, - link: { - linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: 'thread-1' }, - origin: fence === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: now - }, - now - }) - } - - function createRun(coordinator = COORDINATOR) { - return db.createRun({ - objective: 'Structured Chat lease coverage', - coordinatorHandle: coordinator.handle, - coordinatorPaneKey: paneKey(coordinator) - }) - } - - function queueRunMessage(runId: string) { - return db.insertMessage({ - from: 'term_sender', - to: `run:${runId}`, - subject: 'Queued guidance', - type: 'status', - runId - }) - } - - async function rpc(method: string, params: Record, capability?: string) { - return dispatcher.dispatch({ - id: `request-${method}-${Math.random()}`, - authToken: 'test-token', - method, - params, - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: `mutation-${method}-${Math.random()}`, - orchestrationCapability: capability - }) - } - - it('retains a Run mailbox pointer while Structured Chat refuses the PTY write', () => { - const run = createRun(WORKER) - const message = queueRunMessage(run.id) - - runtime.deliverPendingMessagesForHandle(`run:${run.id}`) - - expect(db.getMessageById(message.id)?.delivered_at).toBeNull() - expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) - }) - - it('redrives the same retained pointer after the lease returns to TUI', async () => { - vi.useFakeTimers() - const run = createRun(WORKER) - const message = queueRunMessage(run.id) - runtime.deliverPendingMessagesForHandle(`run:${run.id}`) - expect(db.getMessageById(message.id)?.delivered_at).toBeNull() - - await establishOwner('tui', 'spawn-tui', 1) - runtime.deliverPendingMessagesForHandle(`run:${run.id}`) - await vi.advanceTimersByTimeAsync(500) - - expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() - expect(writes).toHaveBeenCalledTimes(2) - expect(writes.mock.calls[0]?.[1]).toContain('orchestration check') - expect(writes.mock.calls[1]).toEqual([WORKER.ptyId, '\r']) - }) - - it('fails worker-start truthfully when its reused terminal is in Structured Chat', async () => { - const run = createRun() - const task = db.createTask({ spec: 'Run the queued work', runId: run.id }) - - const response = await rpc('orchestration.workerStart', { - task: task.id, - worktree: 'current', - terminal: WORKER.handle, - from: COORDINATOR.handle - }) - - if (!response.ok) { - throw new Error(response.error.message) - } - expect(response.ok).toBe(true) - expect(response.result).toMatchObject({ - state: 'failed', - failedStage: 'dispatch_input', - lastError: expect.stringMatching(/Structured Chat.*Switch it to Terminal/), - agentSessionRefusal: { code: 'agent_session_conflict', ownerRuntimeKind: 'native' } - }) - expect(db.getTask(task.id)?.status).toBe('failed') - expect(db.getDispatchContext(task.id)?.status).toBe('failed') - expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) - }) - - it('reports a real gate refusal with typed metadata and zero bytes written', async () => { - const response = await rpc('terminal.send', { - terminal: WORKER.handle, - text: 'new prompt', - enter: true, - agentPrompt: true, - client: { id: 'test-client', type: 'desktop' } - }) - - expect(response.ok).toBe(true) - if (!response.ok) { - throw new Error(response.error.message) - } - expect(response.result).toMatchObject({ - send: { - handle: WORKER.handle, - accepted: false, - bytesWritten: 0, - agentSessionRefusal: expect.objectContaining({ - code: 'agent_session_conflict', - ownerRuntimeKind: 'native' - }) - } - }) - expect(response.result).toMatchObject({ - mutation: { requestId: expect.stringMatching(/^mutation-terminal\.send-/), replayed: false } - }) - expect(writes).not.toHaveBeenCalled() - }) - - it('stops a prompt when Structured Chat takes the lease between paste chunks', async () => { - await establishOwner('tui', 'spawn-tui', 1) - let writesStarted = 0 - - await expect( - runtime.sendTerminalAgentPrompt(WORKER.handle, 'x'.repeat(20_000), { - beforeWrite: async () => { - writesStarted += 1 - if (writesStarted === 2) { - await establishOwner('native', 'spawn-native-transfer', 2) - } - } - }) - ).rejects.toMatchObject({ - refusal: expect.objectContaining({ - code: 'agent_session_conflict', - ownerRuntimeKind: 'native' - }) - }) - - expect(writes).toHaveBeenCalledTimes(1) - expect(writes.mock.calls[0]?.[1]).not.toBe('\r') - }) - - it('withholds delayed pointer Enter when Structured Chat takes the lease', async () => { - vi.useFakeTimers() - await establishOwner('tui', 'spawn-tui', 1) - const run = createRun(WORKER) - const message = queueRunMessage(run.id) - - runtime.deliverPendingMessagesForHandle(`run:${run.id}`) - expect(writes).toHaveBeenCalledTimes(1) - await establishOwner('native', 'spawn-native-before-enter', 2) - await vi.advanceTimersByTimeAsync(500) - - expect(writes).toHaveBeenCalledTimes(1) - expect(db.getMessageById(message.id)).toMatchObject({ read: 0 }) - }) - - it('settles worker_done while its pane remains in Structured Chat', async () => { - const run = createRun() - const task = db.createTask({ spec: 'Finish from Structured Chat', runId: run.id }) - const dispatch = db.createDispatchContext({ - taskId: task.id, - assigneeHandle: WORKER.handle, - assigneePaneKey: paneKey(WORKER), - processIncarnation: runtime.getTerminalProcessIncarnation(WORKER.handle) ?? undefined, - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER - }) - const capability = db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: paneKey(WORKER), - processIncarnation: runtime.getTerminalProcessIncarnation(WORKER.handle)! - }) - - const response = await rpc( - 'orchestration.send', - { - from: WORKER.handle, - subject: 'Done', - body: 'Implemented the task. Verified the result. Nothing remains.', - type: 'worker_done', - payload: JSON.stringify({ - taskId: task.id, - dispatchId: dispatch.id, - outcome: 'succeeded' - }) - }, - capability - ) - - expect(response.ok).toBe(true) - expect(db.getTask(task.id)?.status).toBe('completed') - expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') - expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) - }) - - it('delivers normally to a never-adopted terminal', async () => { - vi.useFakeTimers() - const run = createRun(PLAIN) - const message = queueRunMessage(run.id) - - expect(agentSessionPtyWriteGate.admit(PLAIN.ptyId)).toEqual({ - admitted: true, - sessionId: null, - runtimeFence: null - }) - runtime.deliverPendingMessagesForHandle(`run:${run.id}`) - await vi.advanceTimersByTimeAsync(500) - - expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() - expect(writes).toHaveBeenCalledTimes(2) - expect(writes.mock.calls[1]).toEqual([PLAIN.ptyId, '\r']) - }) -}) diff --git a/src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts b/src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts deleted file mode 100644 index 6eb8398cc608..000000000000 --- a/src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { WriteSettlement } from '../../../shared/pty-write-settlement' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' -import { OrcaRuntimeWithWriteOrchestrationPointerPty } from '../orca-runtime-write-orchestration-pointer-pty' -import { OrcaRuntimeWithGetPtyRecordForPaneKey } from '../orca-runtime-get-pty-record-for-pane-key' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' - -const SESSION_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' -const PTY_ID = 'pty_adopted' - -// Both methods are protected, and a subclass is the sanctioned way to reach them. The probes -// borrow the REAL implementations through the real prototype chain; a re-declared copy would pin -// nothing. -class PointerWriteProbe extends OrcaRuntimeWithWriteOrchestrationPointerPty { - probeWritePointer(ptyId: string, data: string): WriteSettlement | Promise { - return this.writeOrchestrationPointerPty(ptyId, data) - } -} - -class MailboxTargetProbe extends OrcaRuntimeWithGetPtyRecordForPaneKey { - probeResolveTarget(mailboxHandle: string): unknown { - return this.resolveStructuredMailboxTarget(mailboxHandle) - } -} - -/** A probe instance whose prototype chain is the real class, with only its state stubbed. */ -function probe( - prototype: TProbe, - state: TState -): TProbe & TState { - return Object.assign(Object.create(prototype), state) as TProbe & TState -} - -/** A pane bound to a session a settled NATIVE owner holds — the adopted-TUI state. */ -function bindNativeOwnedPane(overrides: Partial = {}): void { - agentSessionPtyWriteGate.attachRecordLookup( - (sessionId) => - ({ - sessionId, - location: { executionHostId: 'local', wslDistro: null }, - lease: { - sessionId, - runtimeKind: 'native', - claimStatus: 'live', - handoffStage: null, - unreconciled: false, - ownerProcess: { pid: 4242 }, - runtimeFence: 7, - ...overrides - } - }) as unknown as AgentSessionRecord - ) - agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) -} - -afterEach(() => { - agentSessionPtyWriteGate.detachRecordLookup() - vi.restoreAllMocks() -}) - -describe('an orchestration pointer aimed at an adopted pane', () => { - it('reaches no provider and never reports a write failure to the renderer', () => { - bindNativeOwnedPane() - const write = vi.fn(() => true) - const writeWithSettlement = vi.fn(async () => true) - const stub = { - orchestrationPointerAdmissionByPtyId: new Map(), - ptyController: { write, writeWithSettlement } - } - // Zero bytes: the controller path would re-admit, refuse again, and fire - // `pty:writeUnavailable`, whose renderer handler runs transport RECOVERY on a healthy pane. - // A proven refusal, not a bare false: the settlement vocabulary keeps "declined before any - // byte moved" distinct from "we lost track", which is what a durable reservation reads. - expect( - probe(PointerWriteProbe.prototype, stub).probeWritePointer( - PTY_ID, - 'You have 1 orchestration message.' - ) - ).toEqual({ outcome: 'refused', reason: 'write_gate_denied' }) - expect(write).not.toHaveBeenCalled() - expect(writeWithSettlement).not.toHaveBeenCalled() - }) - - it('still writes through when nothing owns the pane', () => { - const write = vi.fn(() => true) - // The gate admits an unbound pane, so the bytes reach the provider and its own settlement is - // what the caller gets back. - const writeWithSettlement = vi.fn(() => ({ outcome: 'accepted' }) as const) - const stub = { - orchestrationPointerAdmissionByPtyId: new Map(), - ptyController: { write, writeWithSettlement } - } - expect( - probe(PointerWriteProbe.prototype, stub).probeWritePointer('pty_unbound', 'pointer') - ).toEqual({ outcome: 'accepted' }) - expect(writeWithSettlement).toHaveBeenCalledTimes(1) - }) -}) - -describe('the mailbox target for an adopted pane', () => { - function targetStub() { - return probe(MailboxTargetProbe.prototype, { - _orchestrationDb: { - getDispatchContextById: () => ({ assignee_handle: 'term_adopted' }) - }, - getLiveLeafForHandle: () => ({ leaf: { ptyId: PTY_ID } }) - }) - } - - it('routes the mailbox to the owning session so the nudge travels as a turn', () => { - bindNativeOwnedPane() - const target = targetStub().probeResolveTarget('dispatch:d1') as { - sessionId: string - dispatchId: string - refusal?: { ownerRuntimeKind: string } - } | null - expect(target).toMatchObject({ sessionId: SESSION_ID, dispatchId: 'd1' }) - expect(target?.refusal?.ownerRuntimeKind).toBe('native') - }) - - it('leaves a mid-handoff lease to the PTY lane', () => { - bindNativeOwnedPane({ handoffStage: 'preparing' }) - expect(targetStub().probeResolveTarget('dispatch:d1')).toBeNull() - }) - - it('leaves an unowned pane to the PTY lane', () => { - expect(targetStub().probeResolveTarget('dispatch:d1')).toBeNull() - }) -}) diff --git a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts index e819a4a08861..8dbedc9b33cf 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts @@ -1,10 +1,5 @@ -import { - agentSessionPtyWriteGate, - type AgentSessionPtyWriteAdmittance -} from '../agent-session-pty-write-gate' import type { RuntimePtyController } from '../runtime-pty-controller-contract' import { - WRITE_ACCEPTED, writeRefused, writeUnverifiable, type WriteSettlement @@ -13,23 +8,17 @@ import { export type OrchestrationPointerWriteArgs = { ptyId: string data: string - admissionByPtyId: Map controller: RuntimePtyController | null | undefined } /** - * Every orchestration pointer byte, including the Enter frame, settles through here. Split from - * the lease gate on purpose: a throw before the controller is reached proves no byte left, while - * a throw from the controller cannot, and collapsing the two is what cleared durable mailbox - * reservations for writes that may already have been on the wire. + * Every orchestration pointer byte, including the Enter frame, settles through here. A throw from + * the controller cannot prove no byte left, and collapsing it into a refusal is what cleared + * durable mailbox reservations for writes that may already have been on the wire. */ export function writeOrchestrationPointerWithSettlement( args: OrchestrationPointerWriteArgs ): WriteSettlement | Promise { - const gated = admitOrchestrationPointerWrite(args) - if (gated) { - return gated - } const settledWrite = args.controller?.writeWithSettlement if (!settledWrite) { return writeRefused('provider_cannot_settle') @@ -41,46 +30,3 @@ export function writeOrchestrationPointerWithSettlement( return writeUnverifiable('provider_threw_after_handoff', true) } } - -/** Settles the write itself when the lease gate decides it; null means proceed to the provider. */ -function admitOrchestrationPointerWrite( - args: OrchestrationPointerWriteArgs -): WriteSettlement | null { - const { ptyId, data, admissionByPtyId, controller } = args - try { - if (data === '\r') { - const admitted = admissionByPtyId.get(ptyId) - admissionByPtyId.delete(ptyId) - if (admitted) { - // Throws when the lease moved under the in-flight pointer, withholding the submit. - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - return null - } - // A denied bound lease must not receive a raw Enter, even when it did not follow a - // pointer write. Keep unbound legacy terminals on the existing controller path. - const admission = agentSessionPtyWriteGate.admit(ptyId) - return !admission.admitted && agentSessionPtyWriteGate.boundSessionId(ptyId) !== null - ? writeRefused('write_gate_denied') - : null - } - const admission = agentSessionPtyWriteGate.admit(ptyId) - if (!admission.admitted) { - admissionByPtyId.delete(ptyId) - if (agentSessionPtyWriteGate.boundSessionId(ptyId) !== null) { - return writeRefused('write_gate_denied') - } - // Preserve the controller's own refusal reporting for internal deliveries. - return controller?.write(ptyId, data) - ? WRITE_ACCEPTED - : writeRefused('provider_refused_write') - } - admissionByPtyId.set(ptyId, { - sessionId: admission.sessionId, - runtimeFence: admission.runtimeFence - }) - return null - } catch { - // Every throw here happens before the controller is reached, so no byte can have left. - return writeRefused('write_gate_denied') - } -} diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index a181b89aebc0..048fc7732e40 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' import { OrchestrationStructuredMailboxPointerDelivery, type StructuredMailboxPointerHost @@ -75,7 +74,6 @@ function attentionJournal(): AgentJournalRenderItem[] { function harness(options: { journal: AgentJournalRenderItem[] | null dispatchState?: 'accepted' | 'rejected' | 'unknown' - refusal?: AgentSessionPtyWriteRefusal /** The coordinator of this worker's Run is mid-batch: it checked and has not acked yet. */ outstandingRunDelivery?: boolean outstandingOwnDelivery?: boolean @@ -109,13 +107,7 @@ function harness(options: { getDb: () => db as never, getMessageWaiters: () => undefined, resolveStructuredTarget: (mailboxHandle) => - mailboxHandle === mailbox - ? { - sessionId: IDENTITY.sessionId, - dispatchId, - ...(options.refusal ? { refusal: options.refusal } : {}) - } - : null, + mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, host: { readGateFacts: () => (journal === null ? null : structuredSessionGateFacts(journal)), currentFence: () => 4, @@ -305,41 +297,6 @@ describe('structured mailbox pointer delivery', () => { }) }) -describe('an adopted pane is redirected through its native owner', () => { - const settled: AgentSessionPtyWriteRefusal = { - code: 'agent_session_conflict', - sessionId: 'session-1', - ownerRuntimeKind: 'native', - handoffStage: null, - ownerPid: 4242, - runtimeFence: 7 - } - - it('sends through the session when the refusal names a settled native owner', async () => { - const { delivery, send, markAsDelivered } = harness({ - journal: idleJournal(), - refusal: settled - }) - delivery.deliverForHandle('dispatch:d1') - await flush() - expect(send).toHaveBeenCalledTimes(1) - expect(markAsDelivered).toHaveBeenCalledWith(['m1']) - }) - - it('retains rather than redirecting into a lease that is handing back to a TUI', async () => { - // Re-checked at SEND time: the owner can settle differently between resolve and send, and - // redirecting into a mid-handoff lease races the takeover. - const { delivery, send, markAsDelivered } = harness({ - journal: idleJournal(), - refusal: { ...settled, handoffStage: 'preparing' } - }) - delivery.deliverForHandle('dispatch:d1') - await flush() - expect(send).not.toHaveBeenCalled() - expect(markAsDelivered).not.toHaveBeenCalled() - }) -}) - describe('forgetting one settled worker', () => { /** Two workers, each mid-turn and so each parked on its OWN session's journal edge. */ function twoWorkerHarness() { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 3801bae5959c..62a555d41191 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -21,16 +21,13 @@ import { } from './mailbox-pointer-eligibility' import { resolveStructuredPointerOperation } from './structured-pointer-operation-id' import { - decideStructuredPointerDelivery, decideStructuredSessionPointerDelivery, retainReasonForDispatch, - retainWaitsForJournalEdge, structuredDispatchDelivered, type StructuredDispatchState, type StructuredPointerRetainReason, type StructuredSessionGateFacts } from './structured-session-pointer-delivery' -import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' export type StructuredPointerTarget = { sessionId: string @@ -40,8 +37,6 @@ export type StructuredPointerTarget = { * the operation-ledger budget — so a worker between dispatches is nudged, not dropped. */ dispatchId: string | null - /** Present only for an adopted pane, where a PTY write was refused in favour of this owner. */ - refusal?: AgentSessionPtyWriteRefusal } type ParkedPointerDelivery = { @@ -74,10 +69,6 @@ type StructuredPointerDeliveryDependencies { const sessionId = target.sessionId const session = this.deps.host.readGateFacts(sessionId) - // `target.refusal` is the snapshot the resolver already admitted, so this branch re-runs the - // owner test on frozen input and can only agree with it. What actually fences an owner that - // changed since resolution is `expectedRuntimeFence` below: a handoff bumps the lease fence, - // so the send is refused rather than landing in a lease on its way back to a TUI. The branch - // stays because the policy module is the one place that decides, and a later caller may pass - // an owner it did not pre-screen. - const decision = target.refusal - ? decideStructuredPointerDelivery({ session, refusal: target.refusal }) - : decideStructuredSessionPointerDelivery({ session }) + const decision = decideStructuredSessionPointerDelivery({ session }) if (!decision.deliver) { this.retain(mailboxHandle, sessionId, decision.retain, reservedTypes) return @@ -255,7 +238,14 @@ export class OrchestrationStructuredMailboxPointerDelivery< db.deleteStructuredPointerOperation(mailboxHandle) } - /** No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch. */ + /** + * No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch. + * + * Every reason parks for the session's next journal edge. `unknown` may mean the nudge already + * sits in the provider's input queue, so an immediate retry can stack duplicate nudges; + * `session-not-attached` and `dispatch-rejected` park because nothing else notices the re-attach + * or the moved lease, and the dispatch preamble tells workers not to poll. + */ private retain( mailboxHandle: string, sessionId: string, @@ -263,8 +253,6 @@ export class OrchestrationStructuredMailboxPointerDelivery< reservedTypes: ReadonlySet | undefined ): void { this.deps.onRetain?.({ mailboxHandle, sessionId, reason }) - if (retainWaitsForJournalEdge(reason)) { - this.parkedUntilJournalEdge.set(mailboxHandle, { sessionId, reservedTypes }) - } + this.parkedUntilJournalEdge.set(mailboxHandle, { sessionId, reservedTypes }) } } diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts index 09e5010a1045..e2f8b4f6b24a 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts @@ -1,29 +1,12 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' import { - decideStructuredPointerDelivery, - isSettledNativeOwner, + decideStructuredSessionPointerDelivery, retainReasonForDispatch, - retainWaitsForJournalEdge, structuredDispatchDelivered, structuredSessionGateFacts } from './structured-session-pointer-delivery' -function refusal( - overrides: Partial = {} -): AgentSessionPtyWriteRefusal { - return { - code: 'agent_session_conflict', - sessionId: 'session-1', - ownerRuntimeKind: 'native', - handoffStage: null, - ownerPid: 4242, - runtimeFence: 7, - ...overrides - } -} - function statusItem( turnLifecycle: { turnId: string; state: 'running' } | undefined ): AgentJournalRenderItem { @@ -57,24 +40,6 @@ function pendingApproval(): AgentJournalRenderItem { const IDLE = { turnRunning: false, awaitingHuman: false } -describe('structured pointer owner admission', () => { - it('accepts only a settled native owner', () => { - expect(isSettledNativeOwner(refusal())).toBe(true) - }) - - it('refuses a tui owner', () => { - expect(isSettledNativeOwner(refusal({ ownerRuntimeKind: 'tui' }))).toBe(false) - }) - - it('refuses a native owner that is mid-handoff, so a to-tui takeover is not raced', () => { - expect(isSettledNativeOwner(refusal({ handoffStage: 'recovering' }))).toBe(false) - }) - - it('refuses a reconciling refusal even though it names a native owner', () => { - expect(isSettledNativeOwner(refusal({ code: 'execution_owner_reconciling' }))).toBe(false) - }) -}) - describe('structured session gate facts', () => { it('reads an empty journal as idle', () => { expect(structuredSessionGateFacts([])).toEqual(IDLE) @@ -115,15 +80,15 @@ describe('structured session gate facts', () => { }) }) -describe('decideStructuredPointerDelivery', () => { - it('delivers to a settled, attached, idle session', () => { - expect(decideStructuredPointerDelivery({ refusal: refusal(), session: IDLE })).toEqual({ +describe('decideStructuredSessionPointerDelivery', () => { + it('delivers to an attached, idle session', () => { + expect(decideStructuredSessionPointerDelivery({ session: IDLE })).toEqual({ deliver: true }) }) it('retains when the session is not attached on this host', () => { - expect(decideStructuredPointerDelivery({ refusal: refusal(), session: null })).toEqual({ + expect(decideStructuredSessionPointerDelivery({ session: null })).toEqual({ deliver: false, retain: 'session-not-attached' }) @@ -131,8 +96,7 @@ describe('decideStructuredPointerDelivery', () => { it('retains mid-turn rather than delegating the race to the provider', () => { expect( - decideStructuredPointerDelivery({ - refusal: refusal(), + decideStructuredSessionPointerDelivery({ session: { turnRunning: true, awaitingHuman: false } }) ).toEqual({ deliver: false, retain: 'turn-unsettled' }) @@ -140,21 +104,11 @@ describe('decideStructuredPointerDelivery', () => { it('names the human prompt ahead of the turn, so the retain reason is the actionable one', () => { expect( - decideStructuredPointerDelivery({ - refusal: refusal(), + decideStructuredSessionPointerDelivery({ session: { turnRunning: true, awaitingHuman: true } }) ).toEqual({ deliver: false, retain: 'awaiting-human' }) }) - - it('retains when the owner is not a settled native session', () => { - expect( - decideStructuredPointerDelivery({ - refusal: refusal({ handoffStage: 'preparing' }), - session: IDLE - }) - ).toEqual({ deliver: false, retain: 'owner-not-settled-native' }) - }) }) describe('dispatch outcome classification', () => { @@ -172,23 +126,3 @@ describe('dispatch outcome classification', () => { expect(retainReasonForDispatch('unknown')).toBe('dispatch-unknown') }) }) - -describe('retry pacing', () => { - it('parks a nudge that may already be queued until the journal moves again', () => { - expect(retainWaitsForJournalEdge('dispatch-unknown')).toBe(true) - expect(retainWaitsForJournalEdge('turn-unsettled')).toBe(true) - expect(retainWaitsForJournalEdge('awaiting-human')).toBe(true) - }) - - it('parks a detached session, because the re-attach edge is the only thing that will notice', () => { - expect(retainWaitsForJournalEdge('session-not-attached')).toBe(true) - }) - - it('parks a rejected dispatch, because nothing else retries and no mail was consumed', () => { - expect(retainWaitsForJournalEdge('dispatch-rejected')).toBe(true) - }) - - it('allows a plain retry only for an owner the resolver would not have named', () => { - expect(retainWaitsForJournalEdge('owner-not-settled-native')).toBe(false) - }) -}) diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index dd8ccf64f719..4071d17a0733 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -4,14 +4,13 @@ * * A structured session has no PTY the pointer can be typed into, so the nudge * travels as a session turn instead of as bytes. Everything here is pure: the - * caller supplies the refusal and the session's gate facts, and gets back a - * decision it can act on. Orchestration's database stays the source of truth — + * caller supplies the session's gate facts, and gets back a decision it can + * act on. Orchestration's database stays the source of truth — * no decision here ever consumes mail, it only says whether the nudge may be * attempted now. */ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' import { activeStructuredAgentSessionTurnId, projectStructuredAgentSessionStatus @@ -19,7 +18,6 @@ import { /** Every reason retains the pointer; none of them consume mail. */ export type StructuredPointerRetainReason = - | 'owner-not-settled-native' | 'session-not-attached' | 'turn-unsettled' | 'awaiting-human' @@ -33,20 +31,6 @@ export type StructuredPointerDecision = /** The dispatch states both provider adapters converge on. */ export type StructuredDispatchState = 'accepted' | 'rejected' | 'unknown' -/** - * A refusal names an owner this pointer may be redirected to only when that - * owner is native AND settled. A recovering or mid-handoff lease also reports - * `native`, but it may become a TUI again, so redirecting there races the - * takeover. - */ -export function isSettledNativeOwner(refusal: AgentSessionPtyWriteRefusal): boolean { - return ( - refusal.ownerRuntimeKind === 'native' && - refusal.code === 'agent_session_conflict' && - refusal.handoffStage === null - ) -} - /** * What the delivery gate needs to know about a session, read once per attempt. * @@ -90,24 +74,6 @@ export function structuredSessionGateFacts( * the one contract that holds for both, and it preserves orchestration's * existing idle-edge-only delivery policy. */ -export function decideStructuredPointerDelivery(input: { - refusal: AgentSessionPtyWriteRefusal - /** Null when the session is not attached to this host. */ - session: StructuredSessionGateFacts | null -}): StructuredPointerDecision { - if (!isSettledNativeOwner(input.refusal)) { - return { deliver: false, retain: 'owner-not-settled-native' } - } - return decideStructuredSessionPointerDelivery(input) -} - -/** - * The same decision for a session that was BORN structured. - * - * There is no PTY write to be refused, so there is no refusal to read an owner off — the caller - * already knows the session is host-owned because it created it. Everything after that gate is - * identical, which is why the adopted-TUI path above delegates here rather than duplicating it. - */ export function decideStructuredSessionPointerDelivery(input: { session: StructuredSessionGateFacts | null }): StructuredPointerDecision { @@ -141,23 +107,3 @@ export function retainReasonForDispatch( ): StructuredPointerRetainReason { return state === 'rejected' ? 'dispatch-rejected' : 'dispatch-unknown' } - -/** - * Whether a retained pointer should be parked for the session's next journal edge, or is cheap - * enough to re-attempt on any later trigger. - * - * `unknown` may mean the nudge is already sitting in the provider's input queue, so an immediate - * retry can stack duplicate nudges that each become a turn later. `session-not-attached` parks for - * the opposite reason: nothing else will ever notice the re-attach, and the dispatch preamble - * tells workers not to poll, so an unparked pointer leaves the worker idle on unread mail. - * `dispatch-rejected` parks for that same reason: a rejection consumes no mail and is usually a - * stale fence or a lease that has since moved, both of which the next journal edge re-reads. - * - * Only `owner-not-settled-native` is excluded, and it is unreachable in practice: the resolver - * refuses to name an unsettled owner, so the pointer falls through to the PTY lane before it can - * be retained here. Phrased as an exclusion so a reason added later parks by default — parking - * only adds a retry edge, while forgetting to park is how mail goes unnoticed. - */ -export function retainWaitsForJournalEdge(reason: StructuredPointerRetainReason): boolean { - return reason !== 'owner-not-settled-native' -} diff --git a/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts b/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts index 86de5481297e..8f1f6035dd8e 100644 --- a/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts +++ b/src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts @@ -53,7 +53,6 @@ const SELF_CLEARING_IN_FLIGHT = new Set([ 'providerVisibleStateReadsByPtyId', 'agentPromptSubmissionTailByPtyId', 'interactiveWaitProbesByPtyId', - 'orchestrationPointerAdmissionByPtyId', 'messageDeliveryFlightsByPtyId', 'parkedMessageRedeliveriesByPtyId' ]) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts index f2dee00b44c7..f91ff690922e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts @@ -3,8 +3,6 @@ import { isAgentPromptStalledError } from '../../../../agent-prompt-submission-v import { isUnknownWorkerStartOutcome, type WorkerSetupReceipt } from './worker-topology' import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' -import { isAgentSessionPtyWriteRefusedError } from '../../../../../../shared/agent-session-pty-write-admission' -import { structuredChatPtyWriteRefusalCopy } from '../../../../../../shared/agent-session-pty-write-refusal-copy' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' export function failWorkerStartWithReceipt(args: { @@ -18,13 +16,7 @@ export function failWorkerStartWithReceipt(args: { launch: OrchestrationWorkerLaunchReceipt mode: WorkerStartModeReceipt }): unknown { - const agentSessionRefusal = isAgentSessionPtyWriteRefusedError(args.error) - ? args.error.refusal - : undefined - const reason = - (agentSessionRefusal && - structuredChatPtyWriteRefusalCopy(agentSessionRefusal, 'worker-start')) ?? - (args.error instanceof Error ? args.error.message : String(args.error)) + const reason = args.error instanceof Error ? args.error.message : String(args.error) const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) const worker = unknown ? args.db.markWorkerStartUnknown(args.dispatchId, args.failedStage, reason) @@ -52,7 +44,6 @@ export function failWorkerStartWithReceipt(args: { mode: args.mode, effects: JSON.parse(worker.effects) as unknown[], residualResources: JSON.parse(worker.residual_resources) as unknown[], - ...(agentSessionRefusal ? { agentSessionRefusal } : {}), ...(releasable ? { recovery: `This start created a terminal that never ran the Task. Close it with: orca orchestration worker-release --dispatch ${args.dispatchId}` diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index 8c234ce2ba4c..7313eb82b944 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -1,4 +1,3 @@ -import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' import { InvalidArgumentError } from '../../core' import type { DriverState, @@ -92,11 +91,6 @@ export function watchSubscriptionLifetime( } export function isTerminalStreamInputRejection(error: unknown): boolean { - // Why: a lease refusal is a deliberate rejection, not a transport failure, so the stream reports - // it through the WriteUnavailable frame old clients already decode rather than a new opcode. - if (isAgentSessionPtyWriteRefusedError(error)) { - return true - } const message = error instanceof Error ? error.message : String(error) return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index c62c70c59057..99c01d902a42 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -1,4 +1,3 @@ -import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' import { InvalidArgumentError, defineMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' @@ -243,18 +242,6 @@ export const TERMINAL_SEND_METHODS = [ ) } catch (error) { mobileFloorClaim.current?.rollback() - if (isAgentSessionPtyWriteRefusedError(error)) { - // Why: name the owner and the stage instead of a bare not-writable, so a client can say - // who holds the session rather than retrying into a lease it will never win. - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - agentSessionRefusal: error.refusal - } - } - } if (acceptedPromptCheckpoint) { return acceptedPromptCheckpoint } diff --git a/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts b/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts deleted file mode 100644 index 94c6731f3842..000000000000 --- a/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { RpcDispatcher } from './dispatcher' -import { TERMINAL_METHODS } from './methods/terminal' -import type { RpcRequest } from './core' -import type { OrcaRuntimeService } from '../orca-runtime' -import { - AgentSessionPtyWriteRefusedError, - type AgentSessionPtyWriteRefusal -} from '../../../shared/agent-session-pty-write-admission' - -// terminal.send is the one write path a paired client can reach, so a lease refusal has to arrive -// as a result it can render — not as a transport error and not as a silent `accepted: false`. - -const REFUSAL: AgentSessionPtyWriteRefusal = { - code: 'agent_session_conflict', - sessionId: 'session-alpha-1', - ownerRuntimeKind: 'native', - handoffStage: 'preparing', - ownerPid: 4242, - runtimeFence: 7 -} - -const rollback = vi.fn() - -function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { - return { - getRuntimeId: () => 'test-runtime', - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), - beginMobileInputFloor: vi.fn(() => ({ commit: async () => {}, rollback })), - ...overrides - } as OrcaRuntimeService -} - -function makeRequest(params: unknown): RpcRequest { - return { id: 'req-1', authToken: 'tok', method: 'terminal.send', params } -} - -async function send(runtime: OrcaRuntimeService, client: { id: string; type: string }) { - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) - return await dispatcher.dispatch(makeRequest({ terminal: 'terminal-1', text: 'hello', client })) -} - -describe('terminal.send under a refusing lease', () => { - it('returns the typed refusal instead of failing the call', async () => { - const runtime = stubRuntime({ - sendTerminal: vi.fn().mockRejectedValue(new AgentSessionPtyWriteRefusedError(REFUSAL)) - }) - - const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) - - expect(response.ok).toBe(true) - if (!response.ok) { - throw new Error(response.error.message) - } - expect(response.result).toEqual({ - send: { - handle: 'terminal-1', - accepted: false, - bytesWritten: 0, - agentSessionRefusal: REFUSAL - } - }) - }) - - it('keeps the refusal additive so an old client still reads accepted: false', async () => { - const runtime = stubRuntime({ - sendTerminal: vi.fn().mockRejectedValue(new AgentSessionPtyWriteRefusedError(REFUSAL)) - }) - - const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) - - expect(response.ok).toBe(true) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = response.result as { send: { accepted: boolean; refusedReason?: string } } - expect(result.send.accepted).toBe(false) - // A new `refusedReason` value would reach old clients as an unknown enum member. - expect(result.send.refusedReason).toBeUndefined() - }) - - it('releases the mobile input floor a refused send never used', async () => { - rollback.mockClear() - const runtime = stubRuntime({ - sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { - options?.reserveWrite?.('pty-1') - throw new AgentSessionPtyWriteRefusedError(REFUSAL) - }) - }) - - await send(runtime, { id: 'mobile-1', type: 'mobile' }) - - expect(rollback).toHaveBeenCalled() - }) - - it('still surfaces unrelated send failures as errors', async () => { - const runtime = stubRuntime({ - sendTerminal: vi.fn().mockRejectedValue(new Error('terminal_not_writable')) - }) - - const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) - - expect(response.ok).toBe(false) - }) -}) diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 9a139efc49c2..7ff6ba4a7f34 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,10 +1,6 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' -import { - agentSessionPtyWriteGate, - type AgentSessionPtyWriteAdmittance -} from './agent-session-pty-write-gate' export type RuntimeTerminalWriteOptions = { signal?: AbortSignal @@ -28,15 +24,12 @@ export class RuntimeTerminalWriter { payload: string, options: RuntimeTerminalWriteOptions = {} ): Promise { - // Why: the lease is checked before the mobile floor is reserved, so a refused send never takes - // a claim it will not use. - const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. const text = typeof action.text === 'string' ? action.text : '' const hasSuffix = action.enter || action.interrupt if (text) { - await this.writeChunks(ptyId, text, options, admitted) + await this.writeChunks(ptyId, text, options) } if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') @@ -52,9 +45,6 @@ export class RuntimeTerminalWriter { options.signal ) } - // Why: the 500ms text/suffix pause is long enough for a handoff to complete, so the submit - // is re-checked against the fence the text was admitted under. - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) try { await options.beforeWrite?.(ptyId) } catch (error) { @@ -63,7 +53,6 @@ export class RuntimeTerminalWriter { } throw error } - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) options.reserveWrite?.(ptyId) if (!this.write(ptyId, suffix)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') @@ -75,7 +64,6 @@ export class RuntimeTerminalWriter { return } await options.beforeWrite?.(ptyId) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) options.reserveWrite?.(ptyId) if (!this.write(ptyId, payload)) { throw new Error('terminal_not_writable') @@ -86,19 +74,12 @@ export class RuntimeTerminalWriter { async writeChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {}, - admitted: AgentSessionPtyWriteAdmittance = agentSessionPtyWriteGate.assertAdmitted(ptyId) + options: RuntimeTerminalWriteOptions = {} ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() - let firstChunk = true while (!chunk.done) { - if (!firstChunk) { - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - } - firstChunk = false await options.beforeWrite?.(ptyId) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) options.reserveWrite?.(ptyId) if (!this.write(ptyId, chunk.value)) { throw new Error('terminal_not_writable') diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index aad5bcf62579..91595eb124f8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -43,7 +43,6 @@ import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes } from './structured-agent-session-owner-probe' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -161,7 +160,6 @@ export async function stopStructuredAgentSessionRuntime(options?: { const pending = installing installing = null setStructuredAgentSessionHost(null) - agentSessionPtyWriteGate.detachRecordLookup() const outstanding = [...pendingTeardown] pendingTeardown.clear() const installed = pending ? await pending.catch(() => null) : null @@ -199,7 +197,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise store.getRecord(sessionId)) // Why: only the durable store can identify a provider child lost before record publication. void (deps.reapOrphanChildren ?? stopOrphanAgentSessionChildren)({ store }).catch((error) => { try { @@ -215,119 +212,110 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.handleAdapterEvent(event), - ...(deps.onError ? { onError: deps.onError } : {}), - // Claude publishes an observed exit only after its close ladder and transcript write; Codex - // publishes inside its own exit callback and needs nothing. - drainObservedExits: () => claude.drainObservedExits() - }) - const onDispatchSettledLate = ( - settlement: Parameters[0] - ): void => { - void host?.settleLateDispatch(settlement).catch((error) => - deps.onError?.({ - scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, - error - }) - ) - } - const codex = new CodexStructuredSessionAdapter({ - resolveLaunch: createCodexStructuredLaunchResolver({ - store, - resolveWorkspacePath: deps.resolveWorkspacePath, - resolveEnvironment: resolveCodexEnvironment, - ...(deps.resolveCodexPermissionPolicy - ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } - : {}), - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }), - ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore, - onBackgroundTasksChanged: (sessionId, state) => - host?.publishBackgroundTaskState(sessionId, state), - onDispatchSettledLate, - onPrimaryThreadStoppedRunning: ({ sessionId }) => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - }, - onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { - lifecycle.deliver(event) - } - } - }) - const claude = createStructuredClaudeRuntimeAdapter({ + let host: StructuredAgentSessionHost | null = null + const lifecycle = createStructuredAgentSessionLifecycleDelivery({ + handle: (event) => host?.handleAdapterEvent(event), + ...(deps.onError ? { onError: deps.onError } : {}), + // Claude publishes an observed exit only after its close ladder and transcript write; Codex + // publishes inside its own exit callback and needs nothing. + drainObservedExits: () => claude.drainObservedExits() + }) + const onDispatchSettledLate = ( + settlement: Parameters[0] + ): void => { + void host?.settleLateDispatch(settlement).catch((error) => + deps.onError?.({ + scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, + error + }) + ) + } + const codex = new CodexStructuredSessionAdapter({ + resolveLaunch: createCodexStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, - ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), - ...(deps.resolveClaudeLaunchEnv - ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } - : {}), - resolveClaudeInheritedEnv, - resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudePermissionMode - ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } - : {}), - ...(deps.getClaudeManagedAccountGateSettings - ? { - readClaudeManagedAccountGate: () => - readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) - } - : {}), - onLifecycleEvent: (event) => lifecycle.deliver(event), - onBackgroundTasksChanged: (sessionId, state) => - host?.publishBackgroundTaskState(sessionId, state), - onDispatchSettledLate, - ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore - }) - const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) - }) - host = new StructuredAgentSessionHost({ - store, - adapter, - recoveryCapsule: new AgentSessionRecoveryCapsule(deps.stateDirectory), - journalRoot: deps.stateDirectory, - claimKeyId: deps.claimKeyId, - probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), - probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), - ...(deps.resolveLaunchArgs - ? { - resolveLaunchArgs: async (provider: AgentSessionRecord['provider']) => - await deps.resolveLaunchArgs!(provider) - } + resolveEnvironment: resolveCodexEnvironment, + ...(deps.resolveCodexPermissionPolicy + ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } : {}), - onEventSinkError: ({ sessionId, error }) => - deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), - ...(deps.onSessionStatusChanged - ? { onSessionStatusChanged: deps.onSessionStatusChanged } - : {}), - ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), - ...(await modelCatalogHostDeps({ store, deps, envResolvers })) - }) - setStructuredAgentSessionHost(host) - return { - host, - adapter, - waitForRecovery: lifecycle.drain + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }), + ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + modelCatalog: agentModelCatalogStore, + onBackgroundTasksChanged: (sessionId, state) => + host?.publishBackgroundTaskState(sessionId, state), + onDispatchSettledLate, + onPrimaryThreadStoppedRunning: ({ sessionId }) => { + void host + ?.releaseUnansweredDispatches({ + sessionId, + reason: DISPATCH_DOUBT_PROVIDER_IDLE + }) + .catch((error) => + deps.onError?.({ + scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, + error + }) + ) + }, + onEvent: (event) => { + if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { + lifecycle.deliver(event) + } } - } catch (error) { - agentSessionPtyWriteGate.detachRecordLookup() - throw error + }) + const claude = createStructuredClaudeRuntimeAdapter({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), + ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), + resolveClaudeInheritedEnv, + resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.resolveClaudePermissionMode + ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } + : {}), + ...(deps.getClaudeManagedAccountGateSettings + ? { + readClaudeManagedAccountGate: () => + readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) + } + : {}), + onLifecycleEvent: (event) => lifecycle.deliver(event), + onBackgroundTasksChanged: (sessionId, state) => + host?.publishBackgroundTaskState(sessionId, state), + onDispatchSettledLate, + ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + modelCatalog: agentModelCatalogStore + }) + const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + }) + host = new StructuredAgentSessionHost({ + store, + adapter, + recoveryCapsule: new AgentSessionRecoveryCapsule(deps.stateDirectory), + journalRoot: deps.stateDirectory, + claimKeyId: deps.claimKeyId, + probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), + probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), + ...(deps.resolveLaunchArgs + ? { + resolveLaunchArgs: async (provider: AgentSessionRecord['provider']) => + await deps.resolveLaunchArgs!(provider) + } + : {}), + onEventSinkError: ({ sessionId, error }) => + deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), + ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), + ...(await modelCatalogHostDeps({ store, deps, envResolvers })) + }) + setStructuredAgentSessionHost(host) + return { + host, + adapter, + waitForRecovery: lifecycle.drain } } diff --git a/src/main/runtime/structured-agent-session-support-probe.test.ts b/src/main/runtime/structured-agent-session-support-probe.test.ts index f55a802e979e..07a2b671f0a7 100644 --- a/src/main/runtime/structured-agent-session-support-probe.test.ts +++ b/src/main/runtime/structured-agent-session-support-probe.test.ts @@ -4,7 +4,6 @@ import { getStructuredAgentSessionHost, setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' const { isWindowsProcessStartTimeAvailable } = vi.hoisted(() => ({ isWindowsProcessStartTimeAvailable: vi.fn(() => true) @@ -23,11 +22,10 @@ function setPlatform(platform: NodeJS.Platform): void { type InstallEffects = { storeOpened: boolean - writeGateAttached: boolean reaperStarted: boolean } -/** Stands in for `install()` by performing the three effects it performs, so a probe that +/** Stands in for `install()` by performing the two effects it performs, so a probe that * reinstalls the host is caught by what the install *does*, not by a call count alone. */ function stubStructuredHostInstall(runtime: OrcaRuntimeService): { effects: InstallEffects @@ -35,7 +33,6 @@ function stubStructuredHostInstall(runtime: OrcaRuntimeService): { } { const effects: InstallEffects = { storeOpened: false, - writeGateAttached: false, reaperStarted: false } // `supportsCreate` answers as the real Codex adapter would, so a probe that reinstalls the host @@ -48,8 +45,6 @@ function stubStructuredHostInstall(runtime: OrcaRuntimeService): { const ensure = vi.fn(async () => { effects.storeOpened = true effects.reaperStarted = true - agentSessionPtyWriteGate.attachRecordLookup(() => null) - effects.writeGateAttached = true setStructuredAgentSessionHost(host as never) }) vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockImplementation(ensure) @@ -101,7 +96,6 @@ async function expectSupportWithoutInstall(input: { expect(ensure).not.toHaveBeenCalled() expect(effects).toEqual({ storeOpened: false, - writeGateAttached: false, reaperStarted: false }) expect(getStructuredAgentSessionHost()).toBeNull() @@ -113,7 +107,6 @@ describe('structured agent-session create-support probe', () => { isWindowsProcessStartTimeAvailable.mockReset() isWindowsProcessStartTimeAvailable.mockReturnValue(true) setStructuredAgentSessionHost(null) - agentSessionPtyWriteGate.detachRecordLookup() vi.restoreAllMocks() }) @@ -202,7 +195,6 @@ describe('structured agent-session create-support probe', () => { expect(ensure).toHaveBeenCalledTimes(1) expect(effects).toEqual({ storeOpened: true, - writeGateAttached: true, reaperStarted: true }) expect( diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 009b9048d720..b2d0891eac69 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,5 +1,4 @@ import { ipcRenderer } from 'electron' -import type { AgentSessionPtyWriteRefusal } from '../../shared/agent-session-pty-write-admission' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { @@ -75,17 +74,9 @@ export const ptySessionControlApi = { }, writeAccepted: (id: string, data: string): Promise => ipcRenderer.invoke('pty:writeAccepted', { id, data }), - onWriteUnavailable: ( - callback: (payload: { - id: string - /** Set only when a durable agent-session lease refused the write; absent otherwise. */ - agentSessionRefusal?: AgentSessionPtyWriteRefusal - }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } - ): void => callback(payload) + onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => + callback(payload) ipcRenderer.on('pty:writeUnavailable', handler) return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) }, diff --git a/src/shared/agent-session-pty-write-admission.test.ts b/src/shared/agent-session-pty-write-admission.test.ts deleted file mode 100644 index 9718fc8d5dc5..000000000000 --- a/src/shared/agent-session-pty-write-admission.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AgentSessionPtyWriteRefusedError, - describeAgentSessionPtyWriteRefusal, - evaluateAgentSessionPtyWriteAdmission, - isAgentSessionPtyWriteRefusedError, - reevaluateAgentSessionPtyWriteAdmission -} from './agent-session-pty-write-admission' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from './agent-session-record.test-fixture' -import type { AgentSessionLease } from './agent-session-record' -import { AGENT_SESSION_RPC_ERROR_CODES } from './agent-session-host-authority' - -function bindingFor(lease: AgentSessionLease) { - return { sessionId: lease.sessionId, record: agentSessionRecordFixture(lease) } -} - -describe('exemptions', () => { - it('admits a PTY with no binding, which is every ordinary shell and legacy agent terminal', () => { - const admission = evaluateAgentSessionPtyWriteAdmission(null) - expect(admission).toEqual({ admitted: true, sessionId: null, runtimeFence: null }) - }) - - it('admits a proven-live TUI owner', () => { - const lease = agentSessionLeaseFixture() - expect(evaluateAgentSessionPtyWriteAdmission(bindingFor(lease))).toEqual({ - admitted: true, - sessionId: lease.sessionId, - runtimeFence: lease.runtimeFence - }) - }) -}) - -describe('refusal matrix', () => { - const cases: { name: string; lease: Partial; code: string }[] = [ - { - name: 'native chat owns the session', - lease: { runtimeKind: 'native' }, - code: 'agent_session_conflict' - }, - { - name: 'a handoff is preparing', - lease: { handoffStage: 'preparing' }, - code: 'agent_session_conflict' - }, - { - name: 'the old owner stopped mid-handoff', - lease: { handoffStage: 'old-owner-stopped' }, - code: 'agent_session_conflict' - }, - { - name: 'the new owner has not proved itself', - lease: { handoffStage: 'new-owner-proving' }, - code: 'agent_session_conflict' - }, - { - name: 'two claims collided', - lease: { claimStatus: 'conflicted' }, - code: 'agent_session_conflict' - }, - { - name: 'the lease is unreconciled after a host restart', - lease: { unreconciled: true }, - code: 'execution_owner_reconciling' - }, - { - name: 'recovery is running', - lease: { handoffStage: 'recovering' }, - code: 'execution_owner_reconciling' - }, - { - name: 'a human must finish recovery', - lease: { handoffStage: 'manual-recovery' }, - code: 'execution_owner_reconciling' - }, - { - name: 'the claim is reserved but no process exists yet', - lease: { claimStatus: 'reserved', ownerProcess: null }, - code: 'agent_session_ownership_unknown' - }, - { - name: 'the owner released the session', - lease: { claimStatus: 'released' }, - code: 'agent_session_ownership_unknown' - } - ] - - for (const testCase of cases) { - it(`refuses when ${testCase.name}`, () => { - const lease = agentSessionLeaseFixture(testCase.lease) - const admission = evaluateAgentSessionPtyWriteAdmission(bindingFor(lease)) - expect(admission.admitted).toBe(false) - if (admission.admitted) { - return - } - expect(admission.refusal.code).toBe(testCase.code) - expect(admission.refusal.sessionId).toBe(lease.sessionId) - expect(admission.refusal.ownerRuntimeKind).toBe(lease.runtimeKind) - expect(admission.refusal.handoffStage).toBe(lease.handoffStage) - expect(admission.refusal.runtimeFence).toBe(lease.runtimeFence) - }) - } - - it('distinguishes a reconciling window from a session another runtime owns', () => { - // Phase-2 clients render "recovering, retry shortly" only when these two do not collapse. - const reconciling = evaluateAgentSessionPtyWriteAdmission( - bindingFor(agentSessionLeaseFixture({ unreconciled: true })) - ) - const owned = evaluateAgentSessionPtyWriteAdmission( - bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) - ) - expect(reconciling.admitted).toBe(false) - expect(owned.admitted).toBe(false) - if (reconciling.admitted || owned.admitted) { - return - } - expect(reconciling.refusal.code).not.toBe(owned.refusal.code) - }) - - it('fails closed when a bound PTY has no readable record', () => { - const admission = evaluateAgentSessionPtyWriteAdmission({ - sessionId: 'session-alpha-1', - record: null - }) - expect(admission.admitted).toBe(false) - if (admission.admitted) { - return - } - expect(admission.refusal.code).toBe('execution_owner_reconciling') - expect(admission.refusal.ownerRuntimeKind).toBeNull() - }) - - it('fails closed when the record answers for a different session', () => { - const admission = evaluateAgentSessionPtyWriteAdmission({ - sessionId: 'session-beta-2', - record: agentSessionRecordFixture() - }) - expect(admission.admitted).toBe(false) - if (admission.admitted) { - return - } - expect(admission.refusal.code).toBe('agent_session_ownership_unknown') - expect(admission.refusal.sessionId).toBe('session-beta-2') - }) - - it('only emits codes old clients already decode', () => { - const emitted = new Set( - cases.map((testCase) => { - const admission = evaluateAgentSessionPtyWriteAdmission( - bindingFor(agentSessionLeaseFixture(testCase.lease)) - ) - return admission.admitted ? 'admitted' : admission.refusal.code - }) - ) - for (const code of emitted) { - expect(AGENT_SESSION_RPC_ERROR_CODES).toContain(code) - } - }) -}) - -describe('in-flight fence race', () => { - const admittedLease = agentSessionLeaseFixture() - const admitted = { sessionId: admittedLease.sessionId, runtimeFence: admittedLease.runtimeFence } - - it('lets the rest of a write land while the same fence still holds', () => { - const next = reevaluateAgentSessionPtyWriteAdmission({ - admitted, - binding: bindingFor(admittedLease) - }) - expect(next.admitted).toBe(true) - }) - - it('refuses the rest of a write once the fence advanced under it', () => { - // A handoff completed between two chunks: the new owner's lease would otherwise admit them. - const moved = agentSessionLeaseFixture({ runtimeFence: admittedLease.runtimeFence + 1 }) - const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: bindingFor(moved) }) - expect(next.admitted).toBe(false) - if (next.admitted) { - return - } - expect(next.refusal.code).toBe('agent_session_checkpoint_stale') - expect(next.refusal.runtimeFence).toBe(moved.runtimeFence) - }) - - it('refuses the rest of a write when the PTY was rebound to another session', () => { - const other = agentSessionLeaseFixture({ sessionId: 'session-beta-2' }) - const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: bindingFor(other) }) - expect(next.admitted).toBe(false) - }) - - it('refuses the rest of a write when the binding disappeared mid-flight', () => { - const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: null }) - expect(next.admitted).toBe(false) - }) - - it('refuses the rest of a write when the lease stopped admitting a writer', () => { - const next = reevaluateAgentSessionPtyWriteAdmission({ - admitted, - binding: bindingFor(agentSessionLeaseFixture({ handoffStage: 'preparing' })) - }) - expect(next.admitted).toBe(false) - if (next.admitted) { - return - } - expect(next.refusal.code).toBe('agent_session_conflict') - }) - - it('judges a write admitted while unbound on whatever binding appeared', () => { - const next = reevaluateAgentSessionPtyWriteAdmission({ - admitted: { sessionId: null, runtimeFence: null }, - binding: bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) - }) - expect(next.admitted).toBe(false) - }) -}) - -describe('typed error', () => { - it('carries the refusal and reports its code as the message', () => { - const admission = evaluateAgentSessionPtyWriteAdmission( - bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) - ) - if (admission.admitted) { - throw new Error('expected a refusal') - } - const error = new AgentSessionPtyWriteRefusedError(admission.refusal) - expect(isAgentSessionPtyWriteRefusedError(error)).toBe(true) - expect(isAgentSessionPtyWriteRefusedError(new Error('agent_session_conflict'))).toBe(false) - expect(error.message).toBe('agent_session_conflict') - expect(error.refusal).toEqual(admission.refusal) - }) - - it('describes who holds the session and what stage it is in', () => { - const admission = evaluateAgentSessionPtyWriteAdmission( - bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'preparing' })) - ) - if (admission.admitted) { - throw new Error('expected a refusal') - } - const described = describeAgentSessionPtyWriteRefusal(admission.refusal) - expect(described).toContain('session-alpha-1') - expect(described).toContain('native chat') - expect(described).toContain('pid 4242') - expect(described).toContain('preparing') - }) -}) diff --git a/src/shared/agent-session-pty-write-admission.ts b/src/shared/agent-session-pty-write-admission.ts deleted file mode 100644 index 78f9b915f69e..000000000000 --- a/src/shared/agent-session-pty-write-admission.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * PTY-write admission for agent sessions that have a durable record. - * - * A PTY is the TUI runtime's write surface, so bytes may enter it only while the session's lease - * admits a writer and that writer is the TUI. The admit decision is `agentSessionLeaseAdmitsWriter` - * verbatim; everything here only decides whether the lease is even the TUI's to hold, and — once - * that helper has already refused — which refusal a client should be shown. - * - * A PTY with no binding is a session this host knows nothing about: every ordinary shell and every - * legacy agent terminal. Those are never consulted and never refused. A binding whose record is - * missing is the opposite case — the record was lost, not absent — and fails closed. - */ - -import { - agentSessionLeaseAdmitsWriter, - isAgentSessionFenceCurrent -} from './agent-session-lease-adjudication' -import type { - AgentSessionHandoffStage, - AgentSessionLease, - AgentSessionOwnerRuntimeKind, - AgentSessionRecord -} from './agent-session-record' - -/** - * Every code is already in `AGENT_SESSION_RPC_ERROR_CODES`, so a refusal reaching an old client - * carries a code it has seen since the host-authority release rather than a new one. - */ -export type AgentSessionPtyWriteRefusalCode = - | 'agent_session_conflict' - | 'agent_session_ownership_unknown' - | 'agent_session_checkpoint_stale' - | 'execution_owner_reconciling' - -export type AgentSessionPtyWriteRefusal = { - code: AgentSessionPtyWriteRefusalCode - sessionId: string - /** Runtime the lease names as owner; null once the record is gone. */ - ownerRuntimeKind: AgentSessionOwnerRuntimeKind | null - handoffStage: AgentSessionHandoffStage | null - /** Pid the lease names, so the refusal can say who holds the session. */ - ownerPid: number | null - runtimeFence: number | null -} - -export type AgentSessionPtyWriteAdmission = - | { admitted: true; sessionId: string | null; runtimeFence: number | null } - | { admitted: false; refusal: AgentSessionPtyWriteRefusal } - -/** One PTY's durable binding: the session it belongs to and that session's record, if readable. */ -export type AgentSessionPtyBinding = { - sessionId: string - record: AgentSessionRecord | null -} - -const UNBOUND_ADMISSION: AgentSessionPtyWriteAdmission = { - admitted: true, - sessionId: null, - runtimeFence: null -} - -/** Runs only after `agentSessionLeaseAdmitsWriter` (or the runtime-kind test) already refused. */ -function classifyRefusal(lease: AgentSessionLease): AgentSessionPtyWriteRefusalCode { - if (lease.unreconciled) { - return 'execution_owner_reconciling' - } - if (lease.claimStatus === 'conflicted') { - return 'agent_session_conflict' - } - if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') { - return 'execution_owner_reconciling' - } - if (lease.handoffStage !== null || lease.runtimeKind !== 'tui') { - // Why: a live native owner and a mid-flight handoff are both "someone else holds it", which is - // actionable in a way "we cannot tell" is not. - return 'agent_session_conflict' - } - return 'agent_session_ownership_unknown' -} - -function refuse( - code: AgentSessionPtyWriteRefusalCode, - sessionId: string, - lease: AgentSessionLease | null -): AgentSessionPtyWriteAdmission { - return { - admitted: false, - refusal: { - code, - sessionId, - ownerRuntimeKind: lease?.runtimeKind ?? null, - handoffStage: lease?.handoffStage ?? null, - ownerPid: lease?.ownerProcess?.pid ?? null, - runtimeFence: lease?.runtimeFence ?? null - } - } -} - -export function evaluateAgentSessionPtyWriteAdmission( - binding: AgentSessionPtyBinding | null -): AgentSessionPtyWriteAdmission { - if (!binding) { - return UNBOUND_ADMISSION - } - const record = binding.record - if (!record) { - // Why: a bound PTY whose record cannot be read is a lost lease, not an unmanaged shell. - return refuse('execution_owner_reconciling', binding.sessionId, null) - } - if (record.sessionId !== binding.sessionId) { - return refuse('agent_session_ownership_unknown', binding.sessionId, record.lease) - } - const lease = record.lease - if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { - return { admitted: true, sessionId: record.sessionId, runtimeFence: lease.runtimeFence } - } - return refuse(classifyRefusal(lease), binding.sessionId, lease) -} - -/** - * Re-admit a write that already began. Chunked input and the text/suffix pause both yield, so a - * lease transition can land between two writes of one logical send; the fence observed at - * admission is what the remaining bytes are checked against. - */ -export function reevaluateAgentSessionPtyWriteAdmission(args: { - admitted: { sessionId: string | null; runtimeFence: number | null } - binding: AgentSessionPtyBinding | null -}): AgentSessionPtyWriteAdmission { - const { admitted, binding } = args - const next = evaluateAgentSessionPtyWriteAdmission(binding) - if (admitted.sessionId === null || admitted.runtimeFence === null) { - // Why: an unbound write that acquires a binding mid-flight is judged on the new binding alone. - return next - } - if (!next.admitted) { - return next - } - const lease = binding?.record?.lease ?? null - if ( - next.sessionId !== admitted.sessionId || - !lease || - !isAgentSessionFenceCurrent(lease, admitted.runtimeFence) - ) { - return refuse('agent_session_checkpoint_stale', admitted.sessionId, lease) - } - return next -} - -export class AgentSessionPtyWriteRefusedError extends Error { - readonly refusal: AgentSessionPtyWriteRefusal - - constructor(refusal: AgentSessionPtyWriteRefusal) { - // Why: callers that already switch on `error.message` as an RPC code keep working unchanged. - super(refusal.code) - this.name = 'AgentSessionPtyWriteRefusedError' - this.refusal = refusal - } -} - -export function isAgentSessionPtyWriteRefusedError( - error: unknown -): error is AgentSessionPtyWriteRefusedError { - return error instanceof AgentSessionPtyWriteRefusedError -} - -/** Human-readable refusal, so a client that only surfaces a message still names the owner. */ -export function describeAgentSessionPtyWriteRefusal(refusal: AgentSessionPtyWriteRefusal): string { - const owner = - refusal.ownerRuntimeKind === null - ? 'no recorded owner' - : `${refusal.ownerRuntimeKind === 'native' ? 'native chat' : 'the agent TUI'}${ - refusal.ownerPid === null ? '' : ` (pid ${refusal.ownerPid})` - }` - const stage = - refusal.handoffStage === null ? 'no handoff in progress' : `handoff ${refusal.handoffStage}` - return `Agent session ${refusal.sessionId} is held by ${owner}; ${stage} (${refusal.code}).` -} diff --git a/src/shared/agent-session-pty-write-refusal-copy.ts b/src/shared/agent-session-pty-write-refusal-copy.ts deleted file mode 100644 index 6820b6ce44ba..000000000000 --- a/src/shared/agent-session-pty-write-refusal-copy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' - -export function structuredChatPtyWriteRefusalCopy( - refusal: AgentSessionPtyWriteRefusal, - action: 'terminal-send' | 'worker-start' -): string | null { - if (refusal.ownerRuntimeKind !== 'native') { - return null - } - return action === 'worker-start' - ? 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' - : 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca terminal send`.' -} diff --git a/src/shared/pty-write-settlement.ts b/src/shared/pty-write-settlement.ts index 057098d29473..e3215e4873d3 100644 --- a/src/shared/pty-write-settlement.ts +++ b/src/shared/pty-write-settlement.ts @@ -15,7 +15,6 @@ export type WriteRefusalReason = | 'endpoint_disconnected' | 'endpoint_awaiting_recovery' | 'encode_failed' - | 'write_gate_denied' | 'provider_unavailable' | 'provider_refused_write' | 'provider_cannot_settle' diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 57560caa7a50..235ef1e6b798 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,4 +1,3 @@ -import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -210,11 +209,6 @@ export type RuntimeTerminalSend = { accepted: boolean bytesWritten: number refusedReason?: 'no-agent' | 'permission' - /** - * Present only when a durable agent-session lease refused the write. Additive and optional: an - * old client sees the `accepted: false` it already handles and ignores this field. - */ - agentSessionRefusal?: AgentSessionPtyWriteRefusal prompt?: RuntimeTerminalPromptDelivery } From 048391506f6f7d3dea8a0a8a25d6eb4220e9125b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:27:15 -0700 Subject: [PATCH 009/101] refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude --- .../claude/claude-transcript-branch-proof.ts | 13 - ...claude-transcript-branch-streaming.test.ts | 87 +++-- ...ude-transcript-marker-branch-proof.test.ts | 342 ++++++++++++++++++ .../journal-legacy-import.test.ts | 150 +++----- .../journal-legacy-import.ts | 24 -- .../native-chat/session-file-resolver.test.ts | 319 ---------------- ...ructured-agent-session-integration.test.ts | 25 +- 7 files changed, 462 insertions(+), 498 deletions(-) create mode 100644 src/main/claude/claude-transcript-marker-branch-proof.test.ts diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts index 38641043b70a..440fad3a3fce 100644 --- a/src/main/claude/claude-transcript-branch-proof.ts +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -137,19 +137,6 @@ async function runPinnedTranscriptPasses( } } -export async function proveClaudeTranscriptBranch( - input: BranchProofInput & { transcriptPath: string } -): Promise { - return runPinnedTranscriptPasses(input.transcriptPath, undefined, async (readLines) => { - const builder = createBranchProof(input) - let index = 0 - for await (const record of readLines()) { - builder.add(record.line, index++, record.terminated) - } - return builder.finish() - }) -} - /** * Prove the branch from the file's last transcript row back to the anchor, then * replay the anchor..tail records off the SAME pinned bytes. Two bounded passes diff --git a/src/main/claude/claude-transcript-branch-streaming.test.ts b/src/main/claude/claude-transcript-branch-streaming.test.ts index 2fa2c1349bde..ce283ce501db 100644 --- a/src/main/claude/claude-transcript-branch-streaming.test.ts +++ b/src/main/claude/claude-transcript-branch-streaming.test.ts @@ -73,28 +73,35 @@ import { appendFile, mkdtemp, rename, rm, truncate, unlink, writeFile } from 'no import { ClaudeTranscriptPreviousCursorMissingError, ClaudeTranscriptTailIncompleteError, - proveClaudeTranscriptBranch, - proveClaudeTranscriptBranchFromJsonl + replayClaudeTranscriptBranchAncestry, + replayClaudeTranscriptBranchAncestryFromJsonl } from './claude-transcript-branch-proof' const row = (uuid: string, parentUuid: string | null, extra = {}) => `${JSON.stringify({ type: 'user', uuid, parentUuid, sessionId: 'provider', ...extra })}\n` const marker = (leafUuid: string, sessionId = 'provider') => `${JSON.stringify({ type: 'last-prompt', leafUuid, sessionId })}\n` +/** Carries no uuid, so it can be neither a tip nor a marker. */ +const SUMMARY = `${JSON.stringify({ type: 'summary', summary: 'title' })}\n` const ROOT = row('root', null) const CHILD = row('child', 'root') const SOURCE = ROOT + marker('root') let directory = '' -const read = (previousLeafUuid: string | null = null, intentionalRewindUuid?: string) => - proveClaudeTranscriptBranch({ +let replayed: string[] = [] +// The live caller always anchors the replay at the cursor it already holds. +const replay = (anchorUuid = 'root') => + replayClaudeTranscriptBranchAncestry({ transcriptPath: state.path, providerSessionId: 'provider', - previousLeafUuid, - ...(intentionalRewindUuid === undefined ? {} : { intentionalRewindUuid }) + previousLeafUuid: anchorUuid, + ancestryAnchorUuid: anchorUuid, + onAncestorRecord: (_record, uuid) => replayed.push(uuid) }) +const read = async (anchorUuid?: string) => (await replay(anchorUuid)).proof beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-branch-streaming-')) + replayed = [] Object.assign(state, { path: join(directory, 'transcript.jsonl'), opens: 0, @@ -121,7 +128,7 @@ afterEach(async () => { } }) -it('decodes UTF-8 across chunks and proves old ancestry beyond large message bodies', async () => { +it('decodes UTF-8 across chunks and replays ancestry beyond large message bodies', async () => { const prefix = JSON.stringify({ type: 'comment', content: '' }).indexOf('"content":"') + 11 const comment = `${JSON.stringify({ type: 'comment', content: `${'x'.repeat(65535 - prefix)}🙂é漢字` })}\n` const contents = @@ -130,13 +137,18 @@ it('decodes UTF-8 across chunks and proves old ancestry beyond large message bod CHILD + marker('child').trimEnd() await writeFile(state.path, contents) - expect(await read('root')).toEqual( - proveClaudeTranscriptBranchFromJsonl({ + const fromString: string[] = [] + expect(await replay()).toEqual( + replayClaudeTranscriptBranchAncestryFromJsonl({ contents, providerSessionId: 'provider', - previousLeafUuid: 'root' + previousLeafUuid: 'root', + ancestryAnchorUuid: 'root', + onAncestorRecord: (_record, uuid) => fromString.push(uuid) }) ) + expect(replayed).toEqual(fromString) + expect(replayed).toEqual(['child']) }) it.each([ @@ -156,31 +168,36 @@ it.each([ it('proves only the original finite prefix while the same file grows', async () => { state.afterStat = () => appendFile(state.path, CHILD + marker('child') + ' '.repeat(1024 * 1024)) - expect(await read()).toEqual({ leafUuid: 'root', relation: 'initial' }) + expect(await read()).toEqual({ leafUuid: 'root', relation: 'same' }) expect(state.bytesRead).toBe(Buffer.byteLength(SOURCE)) }) -it('keeps a proved rewind tied to the original prefix', async () => { - await writeFile(state.path, ROOT + CHILD + marker('root')) - state.afterStat = () => appendFile(state.path, marker('child')) - expect(await read('child', 'root')).toEqual({ leafUuid: 'root', relation: 'intentional-rewind' }) +it('replays the ancestry off the same pinned prefix the proof read', async () => { + const contents = ROOT + CHILD + await writeFile(state.path, contents) + state.afterStat = () => appendFile(state.path, row('grandchild', 'child')) + expect(await replay()).toEqual({ + proof: { leafUuid: 'child', relation: 'descendant' }, + chain: ['child'] + }) + expect(replayed).toEqual(['child']) + expect(state.bytesRead).toBe(Buffer.byteLength(contents) * 2) }) it.each([ - ['empty', '', SOURCE, null], - ['missing marker', ROOT, marker('root'), null], + ['empty', '', SOURCE, 'root'], ['missing previous cursor', SOURCE, CHILD + marker('child'), 'child'] ] as const)( 'finishes a growing %s proof on the same open handle', - async (_name, contents, growth, previous) => { + async (_name, contents, growth, anchor) => { await writeFile(state.path, contents) state.afterStat = () => appendFile(state.path, growth) - expect((await read(previous)).leafUuid).toBe(previous ?? 'root') + expect((await read(anchor)).leafUuid).toBe(anchor) expect(state.opens).toBe(1) } ) -it.each(['', ROOT])('keeps a static missing marker fatal', async (contents) => { +it.each(['', SUMMARY])('keeps a static missing tip fatal', async (contents) => { await writeFile(state.path, contents) await expect(read()).rejects.toThrow('missing last-prompt marker') }) @@ -190,22 +207,22 @@ it('preserves the typed static missing-cursor error for existing root reproof', }) it.each([ - ['conflict', ROOT + row('root', 'foreign') + marker('root'), 'conflicting ancestry'], - ['wrong session', ROOT + marker('root', 'foreign'), 'invalid last-prompt'], - ['append order', CHILD + ROOT + marker('child'), 'parent row follows'], - ['missing ancestor', CHILD + marker('child'), 'missing ancestor'] + ['conflict', ROOT + row('root', 'foreign') + marker('root'), 'root', 'conflicting ancestry'], + ['wrong session', ROOT + marker('root', 'foreign'), 'root', 'invalid last-prompt'], + ['append order', CHILD + ROOT, 'root', 'parent row follows'], + ['missing ancestor', CHILD, 'child', 'missing ancestor'] ] as const)( 'does not turn %s into a retry when the file grows', - async (_name, contents, message) => { + async (_name, contents, anchor, message) => { await writeFile(state.path, contents) state.afterStat = () => appendFile(state.path, CHILD) - await expect(read()).rejects.toThrow(message) + await expect(read(anchor)).rejects.toThrow(message) } ) it('does not infer growth from a failed second stat', async () => { - await writeFile(state.path, ROOT) - state.afterStat = () => appendFile(state.path, marker('root')) + await writeFile(state.path, '') + state.afterStat = () => appendFile(state.path, SOURCE) state.statErrorOn = 2 await expect(read()).rejects.toThrow('missing last-prompt marker') }) @@ -219,7 +236,7 @@ it('keeps reading the original handle after pathname replacement', async () => { }) it('does not use a replacement file to establish growth', async () => { - await writeFile(state.path, ROOT) + await writeFile(state.path, '') state.afterStat = async () => { await rename(state.path, `${state.path}.original`) await writeFile(state.path, ROOT + CHILD + marker('child')) @@ -252,16 +269,18 @@ it('completes a record appended after the first observed extent without caller r }) it('validates the entire refreshed prefix instead of accepting a malformed repair', async () => { - await writeFile(state.path, ROOT) - state.afterStat = () => appendFile(state.path, row('root', 'foreign') + marker('root')) + const conflicting = row('root', 'foreign') + await writeFile(state.path, ROOT + conflicting.slice(0, -5)) + state.afterStat = () => appendFile(state.path, conflicting.slice(-5)) await expect(read()).rejects.toThrow('conflicting ancestry') expect(state.opens).toBe(1) }) it('keeps an unfinished growing repair retryable after one internal refresh', async () => { - await writeFile(state.path, ROOT) - state.afterStat = () => appendFile(state.path, ROOT) + const torn = ROOT.slice(0, -5) + await writeFile(state.path, torn) + state.afterStat = () => appendFile(state.path, ' ') await expect(read()).rejects.toBeInstanceOf(ClaudeTranscriptTailIncompleteError) expect(state.opens).toBe(1) - expect(state.bytesRead).toBe(Buffer.byteLength(ROOT) * 3) + expect(state.bytesRead).toBe(Buffer.byteLength(torn) * 2 + 1) }) diff --git a/src/main/claude/claude-transcript-marker-branch-proof.test.ts b/src/main/claude/claude-transcript-marker-branch-proof.test.ts new file mode 100644 index 000000000000..befb320b9e58 --- /dev/null +++ b/src/main/claude/claude-transcript-marker-branch-proof.test.ts @@ -0,0 +1,342 @@ +// The branch rules a Claude transcript must satisfy, read off the marker tip. The live replay +// proves from the file tail instead and falls back to these rules when no tail row qualifies. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { + ClaudeTranscriptTailIncompleteError, + proveClaudeTranscriptBranchFromJsonl +} from './claude-transcript-branch-proof' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + tempRoots.push(root) + return root +} + +async function proveMarkerLeafUuid( + transcriptPath: string, + providerSessionId: string, + previousLeafUuid: string | null = null +): Promise { + const contents = await readFile(transcriptPath, 'utf8') + return proveClaudeTranscriptBranchFromJsonl({ contents, providerSessionId, previousLeafUuid }) + .leafUuid +} + +describe('Claude transcript marker branch proof', () => { + it('reads Claude last-prompt leaf metadata as the durable branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'leaf-old', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'leaf-current', + parentUuid: 'leaf-old', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'leaf-current', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1', 'leaf-old')).resolves.toBe( + 'leaf-current' + ) + }) + + it('fails closed when a Claude transcript has no branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-no-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + '{"type":"assistant","uuid":"not-a-leaf","parentUuid":null,"sessionId":"session-1"}\n', + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'missing last-prompt marker' + ) + }) + + it('distinguishes an incomplete final Claude JSONL record from durable malformed content', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-torn-tail-') + const transcript = join(root, 'session.jsonl') + await writeFile(transcript, '{"type":"last-prompt"', 'utf8') + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + + await writeFile(transcript, '{"type":"last-prompt"\n', 'utf8') + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.not.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + }) + + it('refuses a Claude marker on a sibling branch', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sibling-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'expected', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'system', uuid: 'sibling', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sibling', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1', 'expected')).rejects.toThrow( + 'sibling branch' + ) + }) + + it('refuses missing and cyclic Claude parent chains', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-invalid-ancestry-') + const missing = join(root, 'missing.jsonl') + const cycle = join(root, 'cycle.jsonl') + await writeFile( + missing, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'leaf', parentUuid: 'absent', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'leaf', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + await writeFile( + cycle, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'left', parentUuid: 'right', sessionId: 'session-1' }, + { type: 'system', uuid: 'right', parentUuid: 'left', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'right', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(missing, 'session-1', 'expected')).rejects.toThrow( + 'missing ancestor absent' + ) + await expect(proveMarkerLeafUuid(cycle, 'session-1', 'expected')).rejects.toThrow( + 'cycle in parentUuid ancestry' + ) + }) + + it('rejects non-transcript and sidechain UUIDs as the durable leaf', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-filter-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { type: 'result', uuid: 'result-frame', parentUuid: 'main-user', sessionId: 'session-1' }, + { + type: 'system', + subtype: 'init', + uuid: 'init-frame', + parentUuid: null, + sessionId: 'session-1' + }, + { type: 'stream_event', uuid: 'stream-frame', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sidechain-assistant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'marker leaf is missing from the session graph' + ) + }) + + it('rejects a main leaf whose ancestry crosses a subagent sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sidechain-ancestry-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'sidechain-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a main leaf whose ancestry crosses a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-ancestry-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a previous cursor descended from a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + proveMarkerLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a latest marker descended from a parent-tool-use cursor sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-descendant-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { + type: 'assistant', + uuid: 'latest-after-sidechain', + parentUuid: 'main-after-sidechain', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'latest-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + proveMarkerLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a post-snapshot descendant whose parent row was observed later', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { + type: 'assistant', + uuid: 'descendant', + parentUuid: 'previous', + sessionId: 'session-1' + }, + { type: 'assistant', uuid: 'previous', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'descendant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1', 'previous')).rejects.toThrow( + 'parent row follows descendant' + ) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 13673626ebc8..259f552dd982 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -5,21 +5,32 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionJournalIdentity, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import type * as TranscriptLineDecoders from '../transcript-line-decoders' import { createLegacyIdentityTracker } from './journal-legacy-identity' -import { - appendLegacyTranscriptMessages, - importLegacyTranscriptIntoJournal -} from './journal-legacy-import' +import { importLegacyTranscriptIntoJournal } from './journal-legacy-import' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' +// No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. +const decodedClaudeOverride = vi.hoisted(() => ({ message: null as NativeChatMessage | null })) + +vi.mock('../transcript-line-decoders', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + decodeClaudeTranscriptLine: (...args: Parameters) => + decodedClaudeOverride.message ?? actual.decodeClaudeTranscriptLine(...args) + } +}) + const CLAUDE_SESSION = '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88' const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' @@ -189,6 +200,7 @@ beforeEach(async () => { }) afterEach(async () => { + decodedClaudeOverride.message = null await rm(root, { recursive: true, force: true }) }) @@ -297,46 +309,6 @@ describe('claude import', () => { }) describe('codex import', () => { - it('upserts live transcript messages without rolling the structured epoch', async () => { - const journal = await open('codex', CODEX_SESSION) - const epoch = journal.epoch - const message = { - id: 'live-tui-message', - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: 'first version' }], - timestamp: 1_800_000_000_000, - source: 'transcript' as const - } - - await appendLegacyTranscriptMessages({ - journal, - agent: 'codex', - sessionId: CODEX_SESSION, - fence: 2, - messages: [message] - }) - await appendLegacyTranscriptMessages({ - journal, - agent: 'codex', - sessionId: CODEX_SESSION, - fence: 2, - messages: [{ ...message, blocks: [{ type: 'text', text: 'final version' }] }] - }) - - expect(journal.epoch).toBe(epoch) - expect(journal.snapshot().items).toMatchObject([ - { - itemId: legacyKey('live-tui-message'), - revision: 2, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'final version' }] - } - } - ]) - }) - it('keys rollout records in the import-scoped namespace, not as app-server ordinals', async () => { const filePath = await writeFixture('rollout.jsonl', CODEX_LINES) const journal = await open('codex', CODEX_SESSION) @@ -396,6 +368,32 @@ describe('codex import', () => { }) }) +type RosterAgents = Extract< + NativeChatMessage['blocks'][number], + { type: 'subagent-group' } +>['agents'] + +async function importDecodedRoster(agents: RosterAgents): Promise { + decodedClaudeOverride.message = { + id: 'legacy-roster', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'subagent-group', groupId: 'group-1', agents }] + } + const filePath = await writeFixture('claude-roster.jsonl', [CLAUDE_LINES[2]]) + const journal = await open('claude', CLAUDE_SESSION) + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath } + }) + expect(result).toMatchObject({ ok: true, imported: 1 }) + return journal +} + describe('payload bounds on import', () => { it('marks a clipped tool result and discards the remainder', async () => { const output = 'y'.repeat(64 * 1024) @@ -436,32 +434,13 @@ describe('payload bounds on import', () => { it('bounds an imported subagent roster by entry count, label and id', async () => { // The import reads an untrusted file: nothing upstream bounded either string. const oversized = 'z'.repeat(20 * 1024) - const journal = await open('claude', CLAUDE_SESSION) - await appendLegacyTranscriptMessages({ - journal, - agent: 'claude', - sessionId: CLAUDE_SESSION, - fence: 1, - messages: [ - { - id: 'legacy-roster', - role: 'assistant', - timestamp: null, - source: 'transcript', - blocks: [ - { - type: 'subagent-group', - groupId: 'group-1', - agents: Array.from({ length: 80 }, (_, index) => ({ - id: index === 0 ? oversized : `task-${index}`, - label: index === 0 ? oversized : `label-${index}`, - state: 'working' as const - })) - } - ] - } - ] - }) + const journal = await importDecodedRoster( + Array.from({ length: 80 }, (_, index) => ({ + id: index === 0 ? oversized : `task-${index}`, + label: index === 0 ? oversized : `label-${index}`, + state: 'working' as const + })) + ) const body = journal.snapshot().items[0]?.body const block = body?.kind === 'message' ? body.blocks[0] : undefined @@ -478,31 +457,10 @@ describe('payload bounds on import', () => { // The id is the roster key: it takes the same bounded-id format the wires // use, so a later wire bound is a no-op instead of a second, merging clip. const head = 'y'.repeat(512) - const journal = await open('claude', CLAUDE_SESSION) - await appendLegacyTranscriptMessages({ - journal, - agent: 'claude', - sessionId: CLAUDE_SESSION, - fence: 1, - messages: [ - { - id: 'legacy-roster-collision', - role: 'assistant', - timestamp: null, - source: 'transcript', - blocks: [ - { - type: 'subagent-group', - groupId: 'group-1', - agents: [ - { id: `${head}-one`, label: 'Audit', state: 'working' as const }, - { id: `${head}-two`, label: 'Audit', state: 'working' as const } - ] - } - ] - } - ] - }) + const journal = await importDecodedRoster([ + { id: `${head}-one`, label: 'Audit', state: 'working' as const }, + { id: `${head}-two`, label: 'Audit', state: 'working' as const } + ]) const body = journal.snapshot().items[0]?.body const block = body?.kind === 'message' ? body.blocks[0] : undefined diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index ed509190038a..cf9ff45805cf 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -62,30 +62,6 @@ export type LegacyImportResult = } | { ok: false; error: string } -export async function appendLegacyTranscriptMessages(input: { - journal: AgentSessionJournal - agent: AgentType - sessionId: string - fence: number - messages: NativeChatMessage[] -}): Promise { - let appended = 0 - for (const message of input.messages) { - await input.journal.appendItem( - { - provider: 'legacy', - agent: input.agent, - sessionId: input.sessionId, - recordId: message.id - }, - legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS), - { fence: input.fence, observedAt: message.timestamp ?? undefined } - ) - appended += 1 - } - return appended -} - export async function importLegacyTranscriptIntoJournal(input: { journal: AgentSessionJournal agent: AgentType diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 1ef945b1ea21..4865df90af9d 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -3,10 +3,6 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { - ClaudeTranscriptTailIncompleteError, - proveClaudeTranscriptBranch -} from '../claude/claude-transcript-branch-proof' import { resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -16,16 +12,6 @@ afterEach(async () => { tempRoots = [] }) -async function readClaudeTranscriptLeafUuid( - transcriptPath: string, - providerSessionId: string, - previousLeafUuid: string | null = null -): Promise { - return ( - await proveClaudeTranscriptBranch({ transcriptPath, providerSessionId, previousLeafUuid }) - ).leafUuid -} - async function makeRoot(prefix: string): Promise { const root = await mkdtemp(join(tmpdir(), prefix)) tempRoots.push(root) @@ -41,311 +27,6 @@ function restoreEnv(key: string, previous: string | undefined): void { } describe('resolveSessionFilePath', () => { - it('reads Claude last-prompt leaf metadata as the durable branch marker', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-leaf-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'leaf-old', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'leaf-current', - parentUuid: 'leaf-old', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'leaf-current', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'leaf-old')).resolves.toBe( - 'leaf-current' - ) - }) - - it('fails closed when a Claude transcript has no branch marker', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-no-leaf-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - '{"type":"assistant","uuid":"not-a-leaf","parentUuid":null,"sessionId":"session-1"}\n', - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'missing last-prompt marker' - ) - }) - - it('distinguishes an incomplete final Claude JSONL record from durable malformed content', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-torn-tail-') - const transcript = join(root, 'session.jsonl') - await writeFile(transcript, '{"type":"last-prompt"', 'utf8') - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toBeInstanceOf( - ClaudeTranscriptTailIncompleteError - ) - - await writeFile(transcript, '{"type":"last-prompt"\n', 'utf8') - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.not.toBeInstanceOf( - ClaudeTranscriptTailIncompleteError - ) - }) - - it('refuses a Claude marker on a sibling branch', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-sibling-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, - { type: 'assistant', uuid: 'expected', parentUuid: 'root', sessionId: 'session-1' }, - { type: 'system', uuid: 'sibling', parentUuid: 'root', sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'sibling', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'expected')).rejects.toThrow( - 'sibling branch' - ) - }) - - it('refuses missing and cyclic Claude parent chains', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-invalid-ancestry-') - const missing = join(root, 'missing.jsonl') - const cycle = join(root, 'cycle.jsonl') - await writeFile( - missing, - [ - { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, - { type: 'assistant', uuid: 'leaf', parentUuid: 'absent', sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'leaf', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - await writeFile( - cycle, - [ - { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, - { type: 'assistant', uuid: 'left', parentUuid: 'right', sessionId: 'session-1' }, - { type: 'system', uuid: 'right', parentUuid: 'left', sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'right', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(missing, 'session-1', 'expected')).rejects.toThrow( - 'missing ancestor absent' - ) - await expect(readClaudeTranscriptLeafUuid(cycle, 'session-1', 'expected')).rejects.toThrow( - 'cycle in parentUuid ancestry' - ) - }) - - it('rejects non-transcript and sidechain UUIDs as the durable leaf', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-leaf-filter-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'sidechain-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - isSidechain: true - }, - { type: 'result', uuid: 'result-frame', parentUuid: 'main-user', sessionId: 'session-1' }, - { - type: 'system', - subtype: 'init', - uuid: 'init-frame', - parentUuid: null, - sessionId: 'session-1' - }, - { type: 'stream_event', uuid: 'stream-frame', parentUuid: null, sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'sidechain-assistant', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'marker leaf is missing from the session graph' - ) - }) - - it('rejects a main leaf whose ancestry crosses a subagent sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-sidechain-ancestry-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'sidechain-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - isSidechain: true - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'sidechain-assistant', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'not on the main transcript' - ) - }) - - it('rejects a main leaf whose ancestry crosses a parent-tool-use sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-ancestry-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'subagent-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - parent_tool_use_id: 'tool-use-1' - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'subagent-assistant', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'not on the main transcript' - ) - }) - - it('rejects a previous cursor descended from a parent-tool-use sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'subagent-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - parent_tool_use_id: 'tool-use-1' - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'subagent-assistant', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect( - readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') - ).rejects.toThrow('not on the main transcript') - }) - - it('rejects a latest marker descended from a parent-tool-use cursor sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-descendant-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'subagent-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - parent_tool_use_id: 'tool-use-1' - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'subagent-assistant', - sessionId: 'session-1' - }, - { - type: 'assistant', - uuid: 'latest-after-sidechain', - parentUuid: 'main-after-sidechain', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'latest-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect( - readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') - ).rejects.toThrow('not on the main transcript') - }) - - it('rejects a post-snapshot descendant whose parent row was observed later', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { - type: 'assistant', - uuid: 'descendant', - parentUuid: 'previous', - sessionId: 'session-1' - }, - { type: 'assistant', uuid: 'previous', parentUuid: null, sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'descendant', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'previous')).rejects.toThrow( - 'parent row follows descendant' - ) - }) - it('globs Claude project subdirs for .jsonl', async () => { const root = await makeRoot('orca-native-chat-resolve-claude-') const claudeProjectsDir = join(root, 'claude-projects') diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 2c965b932d5e..3f8adae4906a 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -6,7 +6,7 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,7 +31,7 @@ import type { import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' -import { appendLegacyTranscriptMessages } from '../native-chat/agent-session-journal/journal-legacy-import' +import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import type { OrcaRuntimeService } from './orca-runtime' @@ -407,20 +407,21 @@ describe('a structured codex session over agentSession.*', () => { identity, journalDir: journalDirectoryFor(root, identity) }) - await appendLegacyTranscriptMessages({ + const rollout = join(root, 'legacy-rollout.jsonl') + await writeFile( + rollout, + `${JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'legacy question', kind: 'plain' } + })}\n` + ) + await importLegacyTranscriptIntoJournal({ journal, agent: 'codex', sessionId: THREAD, fence: 0, - messages: [ - { - id: 'legacy-user-1', - role: 'user', - source: 'transcript', - timestamp: 1_800_000_000_000, - blocks: [{ type: 'text', text: 'legacy question' }] - } - ] + options: { filePath: rollout } }) const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( From d0f4dae35091b862386ed5ee9cf74f86a39d286a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:28:09 -0700 Subject: [PATCH 010/101] fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff ()." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude --- .../agent-session-mutation-envelope.test.ts | 20 +++++++++++++++++-- src/shared/agent-session-mutation-envelope.ts | 5 ++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index f488e14e31e2..e929d628de54 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -133,7 +133,7 @@ describe('admitAgentSessionMutation', () => { }) }) - it('refuses a writer mid-handoff', () => { + it('refuses a writer while the chat is still starting', () => { const admission = admitAgentSessionMutation({ ...base, lease: { ...LEASE, handoffStage: 'new-owner-proving' }, @@ -141,7 +141,23 @@ describe('admitAgentSessionMutation', () => { }) expect(admission).toMatchObject({ decision: 'refused', - refusal: { code: 'agent_session_conflict' } + refusal: { code: 'agent_session_conflict', message: 'The chat is still starting.' } + }) + }) + + it('names recovery rather than a handoff when a restart left the owner unproven', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, handoffStage: 'recovering' }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { + code: 'agent_session_conflict', + message: + "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." + } }) }) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index ea99929c6185..97c3d10b0aed 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -146,7 +146,10 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR if (lease.handoffStage !== null) { return { code: 'agent_session_conflict', - message: `The session is mid-handoff (${lease.handoffStage}).` + message: + lease.handoffStage === 'new-owner-proving' + ? 'The chat is still starting.' + : "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." } } return { From faececed4879b5bf4b54799c04ea87dceadfde4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:29:21 -0700 Subject: [PATCH 011/101] test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude --- .../agent-session-journal/journal-legacy-import.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 259f552dd982..92b085e2e0fd 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -20,7 +20,9 @@ import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' // No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. -const decodedClaudeOverride = vi.hoisted(() => ({ message: null as NativeChatMessage | null })) +const decodedClaudeOverride = vi.hoisted((): { message: NativeChatMessage | null } => ({ + message: null +})) vi.mock('../transcript-line-decoders', async (importOriginal) => { const actual = await importOriginal() From 250250e59de4963f5d22e9e0fa0fc68ede010064 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:02:14 -0700 Subject: [PATCH 012/101] refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. --- ...llout-proof.test.ts => codex-pinned-rollout-proof.test.ts} | 2 +- ...dex-tui-rollout-proof.ts => codex-pinned-rollout-proof.ts} | 4 ++-- src/main/codex/codex-structured-launch-resolution.ts | 2 +- src/main/runtime/mobile-rpc-allowlist.test.ts | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) rename src/main/codex/{codex-tui-rollout-proof.test.ts => codex-pinned-rollout-proof.test.ts} (97%) rename src/main/codex/{codex-tui-rollout-proof.ts => codex-pinned-rollout-proof.ts} (94%) diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-pinned-rollout-proof.test.ts similarity index 97% rename from src/main/codex/codex-tui-rollout-proof.test.ts rename to src/main/codex/codex-pinned-rollout-proof.test.ts index 130d167f21e6..d4bc35a03ffc 100644 --- a/src/main/codex/codex-tui-rollout-proof.test.ts +++ b/src/main/codex/codex-pinned-rollout-proof.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' +import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70' const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71' diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-pinned-rollout-proof.ts similarity index 94% rename from src/main/codex/codex-tui-rollout-proof.ts rename to src/main/codex/codex-pinned-rollout-proof.ts index dfab50cae08f..248d2892e320 100644 --- a/src/main/codex/codex-tui-rollout-proof.ts +++ b/src/main/codex/codex-pinned-rollout-proof.ts @@ -3,7 +3,7 @@ import { relativePathInsideRoot } from '../../shared/cross-platform-path' import { readCodexRolloutSessionMetaId } from './codex-rollout-session-meta' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' -export type CodexTuiRolloutProofOptions = { +export type CodexPinnedRolloutProofOptions = { listFiles?: (sessionsRoot: string) => AsyncIterable readSessionMetaId?: (filePath: string) => Promise } @@ -11,7 +11,7 @@ export type CodexTuiRolloutProofOptions = { export async function resolvePinnedCodexRolloutProof( codexHome: string, threadId: string, - options: CodexTuiRolloutProofOptions = {} + options: CodexPinnedRolloutProofOptions = {} ): Promise { const sessionsRoot = join(codexHome, 'sessions') const listFiles = diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 2b0b169b031e..8a3fe002cb1e 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -13,7 +13,7 @@ import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' -import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' +import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' export type CodexStructuredLaunchResolverDeps = { diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 34a4006ec456..06f99c594fb5 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -177,6 +177,5 @@ describe('mobile RPC allowlist', () => { 'agentSession.release' ]) expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false) - expect(mobileRpcAllowlist().has('agentSession.requestHandoff')).toBe(false) }) }) From b58818524d7eea4114b958ecd8b429ffcbee504c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:11:58 -0700 Subject: [PATCH 013/101] refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. --- src/shared/agent-session-wire.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index e6e193b40dd1..468645333157 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -35,26 +35,14 @@ import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' /** `agentSession.handoffStatus`. Named for the removed terminal handoff; released desktop clients - * still read `owner`, and older hosts can still send the terminal-era fields. */ + * still read `owner`. Clients parse the reply as unknown, since older hosts sent more fields. */ export type AgentSessionHandoffStatus = { owner: AgentSessionOwnerRuntimeKind | 'none' - direction: 'to-tui' | 'to-native' | null - phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed' + direction: 'to-native' | null + phase: 'idle' | 'switching' | 'failed' stage: AgentSessionHandoffStage | null operationId: string | null - hostLabel?: string - terminal?: { - handle: string - tabId: string - paneKey: string - ptyId?: string - } - error?: { - message: string - details?: string - recoverableOwner: AgentSessionOwnerRuntimeKind | 'none' - canRetryProof?: boolean - } + error?: { message: string; recoverableOwner: 'none' } } export type { From 35742fd3c0b6b6532d03eda65e9804a7bef30024 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:56:10 -0700 Subject: [PATCH 014/101] refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. --- .../structured-agent-session-host.test.ts | 5 +- ...gent-session-legacy-handoff-record.test.ts | 40 ++++-- .../structured-agent-session-owner-status.ts | 5 +- ...-agent-session-recovery-resolution.test.ts | 64 ++++++--- ...tured-agent-session-recovery-resolution.ts | 4 +- ...ured-agent-session-unexpected-exit.test.ts | 4 +- ...t-session-wedged-profile-migration.test.ts | 24 ++-- ...-session-acquisition-failure-settlement.ts | 1 - .../agent-session-backup-recovery.test.ts | 4 +- ...-session-older-build-lease.test-fixture.ts | 25 ++++ ...session-provider-handle-transition.test.ts | 4 +- ...t-session-record-conversation-name.test.ts | 38 ++--- .../agent-session-record-store-file.ts | 28 ++-- ...-record-store-legacy-handoff-lease.test.ts | 131 ++++++++++++++++++ .../runtime/agent-session-record-store.ts | 12 +- ...gent-session-surface-release-transition.ts | 5 +- ...tured-worker-direct-mailbox-target.test.ts | 3 +- ...ration-structured-worker-lifecycle.test.ts | 9 +- .../runtime/structured-worker-authority.ts | 10 +- .../structured-worker-identity.test.ts | 15 +- .../runtime/structured-worker-identity.ts | 3 +- .../structured-worker-mail-routing.test.ts | 4 +- ...tructured-worker-takeover-pane-key.test.ts | 3 +- .../structured-worker-terminal-read.test.ts | 7 +- .../agent-session-lease-adjudication.test.ts | 17 ++- .../agent-session-lease-adjudication.ts | 7 +- ...agent-session-legacy-handoff-lease.test.ts | 87 ++++++++++++ .../agent-session-legacy-handoff-lease.ts | 89 ++++++++++++ src/shared/agent-session-mutation-envelope.ts | 2 +- .../agent-session-record.test-fixture.ts | 8 +- src/shared/agent-session-record.ts | 42 +++--- src/shared/agent-session-wire.ts | 3 +- 32 files changed, 547 insertions(+), 156 deletions(-) create mode 100644 src/main/runtime/agent-session-older-build-lease.test-fixture.ts create mode 100644 src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts create mode 100644 src/shared/agent-session-legacy-handoff-lease.test.ts create mode 100644 src/shared/agent-session-legacy-handoff-lease.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 0deaf9d9140a..62f88672342c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -607,13 +607,14 @@ describe('restart', () => { expect(listRecords).toHaveBeenCalledTimes(restoreReads) }) - it('clears stale TUI recovery at restart, and reacquires the native owner when a surface holds it', async () => { + it('clears a stale conflicted recovery at restart, and reacquires the native owner when a surface holds it', async () => { await attach() await store.transitionHandoff(SESSION, (record) => ({ ...record, lease: { ...record.lease, - runtimeKind: 'tui', + // How a terminal owner an older build recorded loads. + claimStatus: 'conflicted', handoffStage: 'manual-recovery' } })) 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 d4def4833a8d..42ffe172cd70 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,6 +1,6 @@ // Records an older build persisted mid terminal handoff. That build could leave a lease at // `preparing` or `old-owner-stopped`, or owned by a terminal (`runtimeKind: 'tui'`). This build -// has no handoff to finish, so each state must still load and end in a chat the user can send to. +// has no handoff to finish, so each state loads normalized and ends in a chat the user can send to. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,7 +8,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionLease } from '../../../shared/agent-session-record' +import type { PersistedAgentSessionLease } from '../../../shared/agent-session-legacy-handoff-lease' +import { writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -53,13 +54,12 @@ function openHost(): void { } /** Writes the lease an older build left behind, then starts a fresh app generation over it. */ -async function persistFromOlderBuild(lease: Partial): Promise { +async function persistFromOlderBuild(lease: Partial): Promise { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { ...record.lease, ...lease } - })) + const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() + // Over the attached owner: the older build's stage or terminal owner kept it from releasing. + await writeOlderBuildLease(join(root, 'store'), SESSION, { ...attached, ...lease }) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) acquire.mockClear() openHost() @@ -124,7 +124,11 @@ describe('a record an older build left mid terminal handoff', () => { settlementRetryId: `restart-eviction:${SESSION}:2` }) expect(store.isSessionUnreadable(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('old-owner-stopped') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering', + claimStatus: 'released' + }) await host.restoreReadableSessions() @@ -157,6 +161,7 @@ describe('a record an older build left mid terminal handoff', () => { observedAt: NOW } }) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') await host.restoreReadableSessions() @@ -171,6 +176,11 @@ describe('a record an older build left mid terminal handoff', () => { it('loads a chat owner quiesced for a handoff that never finished, and the chat accepts a send', async () => { await persistFromOlderBuild({ handoffStage: 'preparing', handoffOperationId: `${NOW}-handoff` }) expect(store.isSessionUnreadable(SESSION)).toBe(false) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: 'recovering', + handoffOperationId: `${NOW}-handoff`, + claimStatus: 'live' + }) await host.restoreReadableSessions() @@ -187,15 +197,23 @@ describe('a record an older build left mid terminal handoff', () => { it('waits out a terminal owner that is still running, never stops it, then resumes the chat', async () => { await persistFromOlderBuild({ runtimeKind: 'tui' }) + // A conflicted claim is probed but never stopped, by this build and by older ones. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'conflicted' + }) probe.mockResolvedValue({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) await host.restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - handoffStage: 'recovering' + claimStatus: 'conflicted', + handoffStage: 'manual-recovery' + }) + expect(await send('while the terminal still runs')).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict' } }) - expect(await send('while the terminal still runs')).toMatchObject({ ok: false }) expect(stopOwnerProcess).not.toHaveBeenCalled() expect(acquire).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts index 3e11156b8836..890b8f34feec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -21,10 +21,7 @@ export function structuredAgentSessionOwnerStatus( } } return { - owner: - record.lease.claimStatus === 'live' && record.lease.ownerProcess - ? record.lease.runtimeKind - : 'none', + owner: record.lease.claimStatus === 'live' && record.lease.ownerProcess ? 'native' : 'none', direction: stage ? 'to-native' : null, phase: stage ? 'switching' : 'idle', stage, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index d6e4e341588f..105521bbf985 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -3,6 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { + readPersistedLease, + writeOlderBuildLease +} from '../../runtime/agent-session-older-build-lease.test-fixture' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { resolveStructuredSessionRecovery, @@ -10,6 +14,7 @@ import { } from './structured-agent-session-recovery-resolution' const NOW = 1_800_000_000_000 +const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } const SESSION = 'session-recovery' const roots: string[] = [] let operations = 0 @@ -18,13 +23,20 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -async function openStore(): Promise { +async function newStoreDirectory(): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-recovery-resolution-')) roots.push(root) - return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + return root +} + +async function openStore(directory?: string): Promise { + return AgentSessionRecordStore.open({ + directory: directory ?? (await newStoreDirectory()), + hostId: 'local' + }) } -async function reserve(store: AgentSessionRecordStore, runtimeKind: 'native' | 'tui' = 'native') { +async function reserve(store: AgentSessionRecordStore) { operations += 1 return store.reserveOwner({ sessionId: SESSION, @@ -36,7 +48,7 @@ async function reserve(store: AgentSessionRecordStore, runtimeKind: 'native' | ' }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, - runtimeKind, + runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-recovery', claimKeyId: 'key-1', @@ -51,8 +63,8 @@ async function reserve(store: AgentSessionRecordStore, runtimeKind: 'native' | ' }) } -async function liveOwner(store: AgentSessionRecordStore, runtimeKind: 'native' | 'tui' = 'native') { - const reserved = await reserve(store, runtimeKind) +async function liveOwner(store: AgentSessionRecordStore) { + const reserved = await reserve(store) const fence = reserved.record.lease.runtimeFence await store.commitProcessIdentity({ sessionId: SESSION, @@ -224,21 +236,28 @@ describe('structured session recovery resolution', () => { }) it('waits out a terminal owner an older build recorded, and never stops it', async () => { - const store = await openStore() - await liveOwner(store, 'tui') - await latch(store, 'recovering') + const directory = await newStoreDirectory() + await liveOwner(await openStore(directory)) + await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) + await (await openStore(directory)).reconcileOnRestart({ probe: async () => MATCHED, now: NOW }) + // A fresh load of what that restart persisted, as any later or older build reads it. + const store = await openStore(directory) + await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW }) const stopOwnerProcess = vi.fn() expect( await resolveStructuredSessionRecovery( - deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), { - stopOwnerProcess - }), + deps(store, () => MATCHED, { stopOwnerProcess }), SESSION ) ).toBe('unresolved') expect(stopOwnerProcess).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + // Older builds stop only a native owner that is not conflicted, the same rule as this one. + expect(await readPersistedLease(directory, SESSION)).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'conflicted', + handoffStage: 'manual-recovery' + }) expect( await resolveStructuredSessionRecovery( @@ -252,10 +271,21 @@ describe('structured session recovery resolution', () => { }) }) - it('resolves a TUI reservation that names nobody', async () => { - const store = await openStore() - await reserve(store, 'tui') - await latch(store, 'recovering') + it('resolves a terminal reservation an older build left naming nobody', async () => { + const directory = await newStoreDirectory() + await reserve(await openStore(directory)) + await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) + const store = await openStore(directory) + await store.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no scan' }), + now: NOW + }) + // Only the kind changes: no process is recorded for a conflict to name. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'reserved', + handoffStage: 'manual-recovery' + }) expect( await resolveStructuredSessionRecovery( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index adf142ddb730..40317552d6e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -52,9 +52,9 @@ export function structuredSessionRecoveryIsResolvable(record: AgentSessionRecord } /** Stopping a matched owner is only Orca's call when Orca owned its transport. A conflicted claim - * means ownership was never settled, and a TUI child is the user's foreground agent. */ + * means ownership was never settled — including a terminal an older build recorded as owner. */ function recoveryMayStopOwner(record: AgentSessionRecord): boolean { - return record.lease.runtimeKind === 'native' && record.lease.claimStatus !== 'conflicted' + return record.lease.claimStatus !== 'conflicted' } export async function resolveStructuredSessionRecovery( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index e864a1bc22b5..4ba1a93169b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -458,10 +458,10 @@ describe('provider-exit recovery tickets', () => { expect(isStructuredAgentSessionRecoveryTicketCurrent(recoveryContext({}), ticket)).toBe(true) }) - it('is cancelled by a queued handoff before reattachment', () => { + it('is cancelled by a latched stage before reattachment', () => { expect( isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ handoffStage: 'preparing' }), + recoveryContext({ handoffStage: 'recovering' }), ticket ) ).toBe(false) 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 1305c23313b8..21a5fe4e49d4 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 @@ -19,11 +19,13 @@ import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-a import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionClaimStatus, - AgentSessionHandoffStage, - AgentSessionOwnerRuntimeKind, - AgentSessionProcessIdentity, - AgentSessionRecord + AgentSessionProcessIdentity } from '../../../shared/agent-session-record' +import type { + PersistedAgentSessionHandoffStage, + PersistedAgentSessionRecord, + PersistedAgentSessionRuntimeKind +} from '../../../shared/agent-session-legacy-handoff-lease' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -56,15 +58,15 @@ let acquire: Mock type WedgeOverrides = { claimStatus: AgentSessionClaimStatus - handoffStage: AgentSessionHandoffStage | null - runtimeKind?: AgentSessionOwnerRuntimeKind + handoffStage: PersistedAgentSessionHandoffStage | null + runtimeKind?: PersistedAgentSessionRuntimeKind ownerProcess?: AgentSessionProcessIdentity | null reservedSpawnToken?: string | null handoffOperationId?: string | null } -/** A record in the wedged shape, with real history behind it. */ -function wedgedRecord(overrides: WedgeOverrides): AgentSessionRecord { +/** A record in the wedged shape, as a build may have left it on disk, with real history behind it. */ +function wedgedRecord(overrides: WedgeOverrides): PersistedAgentSessionRecord { const fence = 13 return { schemaVersion: 2, @@ -103,7 +105,7 @@ function wedgedRecord(overrides: WedgeOverrides): AgentSessionRecord { } } -async function seedStore(record: AgentSessionRecord): Promise { +async function seedStore(record: PersistedAgentSessionRecord): Promise { const directory = join(root, 'store') await mkdir(directory, { recursive: true }) await writeFile( @@ -230,7 +232,7 @@ describe('already-wedged profiles become usable on load', () => { handoffStage: null, ownerProcess: DEAD_OWNER }) - const providerRecord: AgentSessionRecord = + const providerRecord: PersistedAgentSessionRecord = provider === 'codex' ? record : { @@ -451,7 +453,7 @@ describe('already-wedged profiles become usable on load', () => { expect(isAcquirable(lease)).toBe(true) }) - it('exits a TUI reservation that crashed before its identity was committed', async () => { + it('exits a TUI reservation an older build left before its identity was committed', async () => { // The reviewer's shape: a TUI child launched, the runtime died before `commitProcessIdentity`, // and restart adjudication could not answer, so the lease latched at `recovering` with a null // owner. Handoff restore cannot help (no owner to talk to) and manual recovery requires one, diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index c790a149f31a..9335dd230bb6 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -74,7 +74,6 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( } assertFence(record.lease, args.fence) if ( - record.lease.runtimeKind !== 'native' || record.lease.claimStatus !== 'live' || record.lease.handoffStage !== null || record.lease.ownerProcess?.spawnToken !== args.spawnToken || diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts index 0ea430774aa5..02866a49fb3b 100644 --- a/src/main/runtime/agent-session-backup-recovery.test.ts +++ b/src/main/runtime/agent-session-backup-recovery.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { evaluateAgentSessionAcquisition } from '../../shared/agent-session-lease-adjudication' -import { isAgentSessionRecord } from '../../shared/agent-session-record' +import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' import { AgentSessionRecordStore } from './agent-session-record-store' import { @@ -313,7 +313,7 @@ describe('recovery from the committed backup', () => { // A `live` lease means a provider handle proven at exactly lease.runtimeFence. Recovery that // rewrote the fence broke that, so the record failed validation, was quarantined on the next // load, and dropped straight back to the same backup. - expect(isAgentSessionRecord(record)).toBe(true) + expect(isPersistedAgentSessionRecord(record)).toBe(true) }) it('carries ownership evidence forward verbatim', async () => { diff --git a/src/main/runtime/agent-session-older-build-lease.test-fixture.ts b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts new file mode 100644 index 000000000000..906921645d4d --- /dev/null +++ b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts @@ -0,0 +1,25 @@ +import { readFile, writeFile } from 'node:fs/promises' +import type { PersistedAgentSessionLease } from '../../shared/agent-session-legacy-handoff-lease' +import { agentSessionStorePath } from './agent-session-record-store-file' + +type StoreFile = { records: Record }> } + +/** Rewrites one lease on disk as an older build left it; this build's types cannot express it. */ +export async function writeOlderBuildLease( + directory: string, + sessionId: string, + fields: Partial +): Promise { + const path = agentSessionStorePath(directory) + const file: StoreFile = JSON.parse(await readFile(path, 'utf-8')) + file.records[sessionId].lease = { ...file.records[sessionId].lease, ...fields } + await writeFile(path, JSON.stringify(file)) +} + +export async function readPersistedLease( + directory: string, + sessionId: string +): Promise> { + const file: StoreFile = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) + return file.records[sessionId].lease +} diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index ddfaa163d732..7a61ea7bdfab 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -4,7 +4,7 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' -import { isAgentSessionRecord } from '../../shared/agent-session-record' +import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { recordAgentSessionProviderHandle, reviseAgentSessionClaudeResumePoint @@ -71,7 +71,7 @@ describe('reviseAgentSessionClaudeResumePoint', () => { handle: { leafUuid: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') - expect(isAgentSessionRecord(second)).toBe(true) + expect(isPersistedAgentSessionRecord(second)).toBe(true) }) it('refuses a stale owner, a released lease, and a head minted by another owner', () => { diff --git a/src/main/runtime/agent-session-record-conversation-name.test.ts b/src/main/runtime/agent-session-record-conversation-name.test.ts index 3c5c55d83c61..084cc93ac4d4 100644 --- a/src/main/runtime/agent-session-record-conversation-name.test.ts +++ b/src/main/runtime/agent-session-record-conversation-name.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isAgentSessionRecord } from '../../shared/agent-session-record' +import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' @@ -8,7 +8,7 @@ const NOW = 9_000 describe('agent session record conversationName validation', () => { it('accepts a record carrying a bounded name', () => { expect( - isAgentSessionRecord({ + isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 'Fix the lease probe' }) @@ -16,33 +16,39 @@ describe('agent session record conversationName validation', () => { }) it('accepts a record with no name at all', () => { - expect(isAgentSessionRecord(agentSessionRecordFixture())).toBe(true) + expect(isPersistedAgentSessionRecord(agentSessionRecordFixture())).toBe(true) }) it('rejects a name past the stored maximum', () => { expect( - isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 'a'.repeat(201) }) + isPersistedAgentSessionRecord({ + ...agentSessionRecordFixture(), + conversationName: 'a'.repeat(201) + }) ).toBe(false) }) it('rejects a name that is not a string', () => { - expect(isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 42 })).toBe( - false - ) - expect(isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: '' })).toBe( - false - ) + expect( + isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 42 }) + ).toBe(false) + expect( + isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: '' }) + ).toBe(false) }) it('rejects persisted names that bypassed canonical normalization', () => { expect( - isAgentSessionRecord({ + isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 'Fix\u202Egnp.exe probe' }) ).toBe(false) expect( - isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 'Fix\nthe probe' }) + isPersistedAgentSessionRecord({ + ...agentSessionRecordFixture(), + conversationName: 'Fix\nthe probe' + }) ).toBe(false) }) }) @@ -57,7 +63,7 @@ describe('setAgentSessionRecordConversationName', () => { expect(next.conversationName).toBe('Fix the lease probe') expect(next.updatedAt).toBe(NOW) - expect(isAgentSessionRecord(next)).toBe(true) + expect(isPersistedAgentSessionRecord(next)).toBe(true) }) it('normalizes on the way in so the record stays valid whatever the caller sent', () => { @@ -68,7 +74,7 @@ describe('setAgentSessionRecordConversationName', () => { ) expect(next.conversationName).toBe('Fix the probe') - expect(isAgentSessionRecord(next)).toBe(true) + expect(isPersistedAgentSessionRecord(next)).toBe(true) }) it('bounds an over-long name rather than storing a record the validator would reject', () => { @@ -79,7 +85,7 @@ describe('setAgentSessionRecordConversationName', () => { ) expect(next.conversationName).toHaveLength(200) - expect(isAgentSessionRecord(next)).toBe(true) + expect(isPersistedAgentSessionRecord(next)).toBe(true) }) it('clears the name via null, deleting the key rather than storing an empty string', () => { @@ -93,7 +99,7 @@ describe('setAgentSessionRecordConversationName', () => { expect(Object.hasOwn(cleared, 'conversationName')).toBe(false) expect(cleared.updatedAt).toBe(NOW + 1) - expect(isAgentSessionRecord(cleared)).toBe(true) + expect(isPersistedAgentSessionRecord(cleared)).toBe(true) }) it('treats a name that normalizes to nothing as a clear', () => { diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 303bb8162d43..b721055e2a93 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -17,9 +17,10 @@ import { } from '../../shared/agent-session-operation-ledger' import { AGENT_SESSION_RECORD_SCHEMA_VERSION, - isAgentSessionRecord, + isPersistedAgentSessionRecord, type AgentSessionRecord } from '../../shared/agent-session-record' +import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { agentSessionStoreBackupPath as backupPath } from './agent-session-record-store-write' export { saveAgentSessionStore } from './agent-session-record-store-write' @@ -55,6 +56,8 @@ export type LoadedAgentSessionStore = { recoveredFromBackup: boolean /** True when the normalized current-schema quarantine must be persisted. */ needsRewrite: boolean + /** True when decode mapped a lease value only the removed terminal handoff wrote. */ + legacyHandoffLeasesNormalized: boolean } export function agentSessionStorePath(directory: string): string { @@ -107,7 +110,10 @@ export function agentSessionStoreRevision(state: AgentSessionStoreState): string function parseState( raw: string, hostId: string -): { state: AgentSessionStoreState; needsRewrite: boolean } | null { +): Pick< + LoadedAgentSessionStore, + 'state' | 'needsRewrite' | 'legacyHandoffLeasesNormalized' +> | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -160,11 +166,17 @@ function parseState( state.schemaVersion = schemaVersion state.hostId = file.hostId let needsRewrite = false + let legacyHandoffLeasesNormalized = false if (typeof file.records === 'object' && file.records !== null) { for (const [sessionId, value] of Object.entries(file.records)) { - const record = isAgentSessionRecord(value) ? value : null + const decoded = isPersistedAgentSessionRecord(value) + ? normalizeLegacyHandoffRecord(value) + : null + const record = decoded?.record ?? null if (record?.sessionId === sessionId) { state.records.set(sessionId, record) + // Why: mapped while parsing, so every revision is taken over the same normalized state. + legacyHandoffLeasesNormalized ||= decoded?.normalized === true } else { const valueSchemaVersion = typeof value === 'object' && @@ -243,7 +255,7 @@ function parseState( } state.visibleSessionIdsIndexPresent = visibleSessionIds.present visibleSessionIds.ids.forEach((sessionId) => state.visibleSessionIds.add(sessionId)) - return { state, needsRewrite } + return { state, needsRewrite, legacyHandoffLeasesNormalized } } /** A record the primary retained as unreadable may still have a valid copy in the previous @@ -311,11 +323,10 @@ export async function loadAgentSessionStore( await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) } return { - state: parsed.state, + ...parsed, storeFound: true, readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION, - recoveredFromBackup, - needsRewrite: parsed.needsRewrite + recoveredFromBackup } } if (unusableStoreFound) { @@ -326,6 +337,7 @@ export async function loadAgentSessionStore( storeFound: false, readOnly: false, recoveredFromBackup: false, - needsRewrite: false + needsRewrite: false, + legacyHandoffLeasesNormalized: false } } diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts new file mode 100644 index 000000000000..997e32fb7cb9 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -0,0 +1,131 @@ +/** + * Decoding a lease the removed terminal handoff wrote. Separate from the store's main suite only + * because that file is at its line cap. + */ + +import { mkdtemp, readFile, 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 { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import { + readPersistedLease, + writeOlderBuildLease +} from './agent-session-older-build-lease.test-fixture' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-legacy-lease-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +const open = (): Promise => + AgentSessionRecordStore.open({ directory, hostId: 'local' }) + +async function persistLiveOwner(): Promise { + const store = await open() + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'no answer' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'0'.repeat(32)}`, + fingerprint: 'fp' + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken: 'spawn-a' }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-1', + origin: 'created', + mintedAtFence: fence, + observedAt: NOW, + handle: { provider: 'claude', sessionId: 'provider-session-alpha', leafUuid: null } + }, + now: NOW + }) +} + +describe('a lease the removed terminal handoff wrote', () => { + it('loads normalized instead of quarantined, and reaches disk with the first transaction', async () => { + await persistLiveOwner() + await writeOlderBuildLease(directory, SESSION, { + runtimeKind: 'tui', + handoffStage: 'old-owner-stopped', + handoffOperationId: 'op-handoff' + }) + const legacy = await readFile(agentSessionStorePath(directory), 'utf-8') + + const store = await open() + expect(store.isSessionUnreadable(SESSION)).toBe(false) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering', + handoffOperationId: 'op-handoff', + claimStatus: 'conflicted' + }) + // An open does not write: another holder of the file mid-restart would read it as a change. + expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(legacy) + + // A transaction that changes nothing still writes it. Had the open's revision been taken over + // anything but the normalized state, this would read the file as externally changed and + // reload it, dropping the pending write. + await store.setSessionTabVisibility(SESSION, false) + expect(await readPersistedLease(directory, SESSION)).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering', + claimStatus: 'conflicted' + }) + }) + + it('keeps the adjudicated lease after a save, so the saved file hashes to memory', async () => { + await persistLiveOwner() + await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) + const store = await open() + await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 1_000 }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + + // A reload after a mismatched hash would mark every lease unadjudicated again. + await store.setSessionTabVisibility(SESSION, true) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'conflicted', + handoffStage: 'manual-recovery' + }) + const settled = await readFile(agentSessionStorePath(directory), 'utf-8') + await open() + expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(settled) + }) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 10566e4eb206..58817a7e59cb 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -90,15 +90,19 @@ export class AgentSessionRecordStore { // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart // grants no writer on the strength of what the previous process wrote. const diskRevision = agentSessionStoreRevision(loaded.state) - // After the revision, so the file still hashes to what was read. The filled ids reach disk - // with this store's first transaction rather than a write here: a rewrite at open would read - // as an external change to any other holder of the file mid-restart. + // After the revision, so the file still hashes to what was read. The filled ids, like the + // normalized legacy leases, reach disk with this store's first transaction rather than a write + // here: a rewrite at open would read as an external change to any other holder of the file + // mid-restart. const backfilled = backfillAgentSessionSurfaceTabIds(loaded.state) markAgentSessionStoreLeasesUnreconciled(loaded.state) const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( filePath, args.hostId, - { ...loaded, needsRewrite: loaded.needsRewrite || backfilled > 0 }, + { + ...loaded, + needsRewrite: loaded.needsRewrite || backfilled > 0 || loaded.legacyHandoffLeasesNormalized + }, diskRevision ) if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index 3d1f6d339544..a6c90d1e01fa 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -15,11 +15,10 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' export type AgentSessionRecordTransitionStore = Pick -/** Whether this record is one THIS host may release on its own proof. A TUI owner, a session - * mid-handoff, and a lease nobody holds are all somebody else's transition. */ +/** Whether this record is one THIS host may release on its own proof. A session mid-handoff and a + * lease nobody holds are somebody else's transition. */ export function isSurfaceReleasableAgentSessionRecord(record: AgentSessionRecord): boolean { return ( - record.lease.runtimeKind === 'native' && record.lease.claimStatus === 'live' && record.lease.handoffStage === null && record.lease.ownerProcess !== null diff --git a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts index e5b36cdf67fa..4fff2d387d0c 100644 --- a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts +++ b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts @@ -104,7 +104,8 @@ describe('the mailbox target for direct peer mail to a structured worker', () => it('leaves a handle whose session this runtime no longer owns to the PTY lane', () => { const handle = registerWorker() - installRecord({ runtimeKind: 'tui', claimStatus: 'live' }) + // How a terminal owner an older build recorded loads; its mail must not park on the session. + installRecord({ runtimeKind: 'native', claimStatus: 'conflicted' }) expect(probe({ id: 'd1' }).instance.probeResolveTarget(handle)).toBeNull() installRecord({ runtimeKind: 'native', claimStatus: 'released' }) expect(probe({ id: 'd1' }).instance.probeResolveTarget(handle)).toBeNull() diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts index 984dad9996f9..a0d0426509b7 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts @@ -42,7 +42,6 @@ function installHost(options: { items?: AgentJournalRenderItem[] hasSession?: boolean claimStatus?: string - runtimeKind?: string deathEvidence?: unknown record?: unknown close?: () => Promise @@ -54,7 +53,7 @@ function installHost(options: { ? { location: { executionHostId: 'local', wslDistro: null }, lease: { - runtimeKind: options.runtimeKind ?? 'native', + runtimeKind: 'native', claimStatus: options.claimStatus ?? 'live', deathEvidence: options.deathEvidence ?? null, runtimeFence: 3 @@ -106,8 +105,8 @@ describe('structured worker observation', () => { expect(observeStructuredWorker(IDENTITY).status).toBe('exited') }) - it('is unverifiable when the lease moved to a terminal owner', () => { - installHost({ runtimeKind: 'tui' }) + it('is unverifiable when a terminal an older build recorded holds the lease', () => { + installHost({ claimStatus: 'conflicted' }) expect(observeStructuredWorker(IDENTITY).status).toBe('unverifiable') }) }) @@ -130,7 +129,7 @@ describe('structured worker stop', () => { it.each([ { hasSession: false }, - { runtimeKind: 'tui' }, + { claimStatus: 'conflicted' }, { claimStatus: 'released' }, { record: null } ])('retains without positive exit evidence: %j', async (options) => { diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 2dbe9dabd0d4..c11119878f63 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -3,8 +3,8 @@ * * The registry holds the handle→session mapping for this process; the durable worker-terminal * resource row is what survives a restart, so a miss falls back to rehydrating from it. The - * durable agent-session record is the liveness half: a session handed to a TUI owner, released, or - * pinned to another execution host is no longer this runtime's structured worker. + * durable agent-session record is the liveness half: a session whose claim is conflicted or + * released, or pinned to another execution host, is no longer this runtime's structured worker. */ import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -117,12 +117,6 @@ export function observeStructuredWorker( if (record.lease.claimStatus === 'released' && record.lease.deathEvidence) { return { status: 'exited' } } - if (record.lease.runtimeKind !== 'native') { - return { - status: 'unverifiable', - reason: 'The session lease is held by a terminal owner, not this structured host.' - } - } if (host.hasSession(identity.sessionId) && record.lease.claimStatus === 'live') { return { status: 'live' } } diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 670859444c9c..98989a85891c 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -19,13 +19,13 @@ import { structuredWorkerRecordIsCurrent } from './structured-worker-identity' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { ExecutionHostId } from '../../shared/execution-host' const SESSION_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' function record(overrides: { - runtimeKind?: 'native' | 'tui' claimStatus?: AgentSessionRecord['lease']['claimStatus'] - executionHostId?: string + executionHostId?: ExecutionHostId wslDistro?: string | null runtimeFence?: number }): AgentSessionRecord { @@ -43,7 +43,7 @@ function record(overrides: { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/me/.claude' }, lease: { sessionId: SESSION_ID, - runtimeKind: overrides.runtimeKind ?? 'native', + runtimeKind: 'native', runtimeFence: overrides.runtimeFence ?? 1, handoffStage: null, provenHandleLinkId: null, @@ -60,7 +60,7 @@ function record(overrides: { }, createdAt: 0, updatedAt: 0 - } as AgentSessionRecord + } } describe('structured worker identity', () => { @@ -170,7 +170,7 @@ describe('structured worker identity', () => { hostId: 'local' }) expect(structuredWorkerHostScope(record({ wslDistro: 'Ubuntu' }).location)).toBeNull() - expect(structuredWorkerHostScope(record({ executionHostId: 'ssh-1' }).location)).toBeNull() + expect(structuredWorkerHostScope(record({ executionHostId: 'ssh:host-1' }).location)).toBeNull() }) it('keeps a recovered session current across a fence bump', () => { @@ -183,8 +183,9 @@ describe('structured worker identity', () => { ) }) - it('refuses a session handed to a TUI owner or released', () => { - expect(structuredWorkerRecordIsCurrent(record({ runtimeKind: 'tui' }))).toBe(false) + it('refuses a conflicted or released session', () => { + // A terminal owner an older build recorded loads conflicted; it is not this worker. + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }))).toBe(false) expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }))).toBe(false) expect(structuredWorkerRecordIsCurrent(null)).toBe(false) }) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index b29d68c297a7..08245281f627 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -137,7 +137,8 @@ export function structuredWorkerRecordIsCurrent( ): boolean { return Boolean( record && - record.lease.runtimeKind === 'native' && + // Why: a conflicted claim may name a terminal an older build recorded as owner, not this worker. + record.lease.claimStatus !== 'conflicted' && record.lease.claimStatus !== 'released' && structuredWorkerHostScope(record.location) ) diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts index d6a1caee8e67..c426132b8485 100644 --- a/src/main/runtime/structured-worker-mail-routing.test.ts +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -80,9 +80,9 @@ describe('bare-handle direct mail to a structured session', () => { expect(getLivePaneKey.call(paneKeyStub, handle)).toBeNull() }) - it('withholds the pane key when the lease moved to a terminal owner', () => { + it('withholds the pane key when a terminal an older build recorded holds the lease', () => { const handle = registerWorker() - installRecord({ runtimeKind: 'tui', claimStatus: 'live' }) + installRecord({ runtimeKind: 'native', claimStatus: 'conflicted' }) expect(getLivePaneKey.call(paneKeyStub, handle)).toBeNull() }) }) diff --git a/src/main/runtime/structured-worker-takeover-pane-key.test.ts b/src/main/runtime/structured-worker-takeover-pane-key.test.ts index 709bdbc8db4a..20930ace637c 100644 --- a/src/main/runtime/structured-worker-takeover-pane-key.test.ts +++ b/src/main/runtime/structured-worker-takeover-pane-key.test.ts @@ -70,7 +70,8 @@ describe('resolving a structured worker takeover by session', () => { it('answers nothing for a session this runtime no longer owns', () => { registerWorker() - installRecord({ runtimeKind: 'tui', claimStatus: 'live' }) + // How a terminal owner an older build recorded loads. + installRecord({ runtimeKind: 'native', claimStatus: 'conflicted' }) expect(runtime().getStructuredWorkerPaneKeyForSession(SESSION_ID)).toBeNull() }) diff --git a/src/main/runtime/structured-worker-terminal-read.test.ts b/src/main/runtime/structured-worker-terminal-read.test.ts index 8d7f4ef6b70b..802b23733bca 100644 --- a/src/main/runtime/structured-worker-terminal-read.test.ts +++ b/src/main/runtime/structured-worker-terminal-read.test.ts @@ -186,8 +186,11 @@ describe('reading a structured worker through the terminal-read path', () => { const handle = registerWorker() installHost({ items: [message('i1', 'x')] }) expect(readStructuredWorkerTerminal({ handle: 'term_abc', db: null })).toBeNull() - // A lease handed to a TUI owner is no longer this runtime's structured worker. - installHost({ items: [message('i1', 'x')], lease: { runtimeKind: 'tui', claimStatus: 'live' } }) + // A terminal owner an older build recorded loads conflicted: not this runtime's worker. + installHost({ + items: [message('i1', 'x')], + lease: { runtimeKind: 'native', claimStatus: 'conflicted' } + }) expect(readStructuredWorkerTerminal({ handle, db: null })).toBeNull() }) }) diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index 9f6043834e10..ff1020ece749 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -8,6 +8,7 @@ import { isProvenDeadProbe, type AgentSessionOwnerProbe } from './agent-session-lease-adjudication' +import { normalizeLegacyHandoffLease } from './agent-session-legacy-handoff-lease' import type { AgentSessionLease } from './agent-session-record' const OWNER = { @@ -143,9 +144,7 @@ describe('acquisition compare-and-swap', () => { it.each([ ['recovering', 'agent_session_ownership_unknown'], - ['manual-recovery', 'agent_session_ownership_unknown'], - ['preparing', 'agent_session_conflict'], - ['old-owner-stopped', 'agent_session_conflict'] + ['manual-recovery', 'agent_session_ownership_unknown'] ] as const)('refuses acquisition in stage %s', (handoffStage, code) => { expect(acquire(lease({ handoffStage }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', @@ -184,14 +183,14 @@ describe('acquisition compare-and-swap', () => { }) describe('restart reconciliation', () => { - it('routes a surviving terminal owner to recovery; nothing re-adopts a terminal', () => { + it('keeps a surviving terminal owner an older build recorded conflicted; nothing re-adopts it', () => { expect( adjudicateAgentSessionRestart({ - lease: lease({ runtimeKind: 'tui' }), + lease: normalizeLegacyHandoffLease({ ...lease(), runtimeKind: 'tui' }), probe: MATCHED, observedAt: 9_000 }) - ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) + ).toMatchObject({ disposition: 'conflicted' }) }) it('routes a surviving native owner to recovery instead of readopting a dead transport', () => { @@ -295,9 +294,9 @@ describe('restart reconciliation', () => { }) }) - it('frees a TUI reservation only when a probe proves nothing ever spawned', () => { - // A TUI child lives in a terminal that outlives the runtime, so absence needs proof. - const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', runtimeKind: 'tui' }) + it('frees an ownerless reservation only when a probe proves nothing ever spawned', () => { + // A child can outlive the runtime that reserved it, so absence needs proof. + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved' }) expect( adjudicateAgentSessionRestart({ lease: reserved, diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index 28ba0270740b..6c1d07820adb 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -145,10 +145,6 @@ export function evaluateAgentSessionAcquisition(args: { // Why: no stage expires into an owner; recovery is resolved by proof or by the user. return { decision: 'refused', code: 'agent_session_ownership_unknown' } } - if (lease.handoffStage !== null && lease.handoffStage !== 'new-owner-proving') { - // Why: a terminal-handoff stage from an older build stays refused until restart retires it. - return { decision: 'refused', code: 'agent_session_conflict' } - } if (lease.handoffStage !== null && lease.handoffOperationId !== null) { if (handoffOperationId !== lease.handoffOperationId) { // Why: the retry key is operation id + fence + stage; a different id is a different intent. @@ -236,8 +232,7 @@ export function adjudicateAgentSessionRestart(args: { } if (isProvenAliveProbe(probe)) { // Why: the surviving child's stdio died with the previous runtime, so readoption would renew - // a lease no host can drive. Recovery stops a chat child and respawns at fence + 1; a terminal - // owner an older build recorded is waited out, never stopped. + // a lease no host can drive. Recovery stops the child and respawns at fence + 1. return { disposition: 'recovering', stage: 'recovering', diff --git a/src/shared/agent-session-legacy-handoff-lease.test.ts b/src/shared/agent-session-legacy-handoff-lease.test.ts new file mode 100644 index 000000000000..b38c411fc6d6 --- /dev/null +++ b/src/shared/agent-session-legacy-handoff-lease.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + leaseCarriesLegacyHandoffValues, + normalizeLegacyHandoffLease, + normalizeLegacyHandoffRecord, + type PersistedAgentSessionLease +} from './agent-session-legacy-handoff-lease' +import type { AgentSessionClaimStatus, AgentSessionHandoffStage } from './agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from './agent-session-record.test-fixture' + +const CLAIMS: AgentSessionClaimStatus[] = ['reserved', 'live', 'conflicted', 'released'] +const STAGES: (AgentSessionHandoffStage | null)[] = [ + null, + 'new-owner-proving', + 'recovering', + 'manual-recovery' +] + +function persisted(overrides: Partial): PersistedAgentSessionLease { + return { ...agentSessionLeaseFixture(), ...overrides } +} + +describe('normalizing a lease the removed terminal handoff wrote', () => { + it('leaves every lease this build writes as it is', () => { + for (const claimStatus of CLAIMS) { + for (const handoffStage of STAGES) { + for (const ownerProcess of [agentSessionLeaseFixture().ownerProcess, null]) { + const lease = agentSessionLeaseFixture({ claimStatus, handoffStage, ownerProcess }) + expect(normalizeLegacyHandoffLease(lease)).toEqual(lease) + expect(leaseCarriesLegacyHandoffValues(lease)).toBe(false) + } + } + } + }) + + it.each(['preparing', 'old-owner-stopped'] as const)( + 'maps the %s stage to recovering and keeps its operation id', + (handoffStage) => { + const lease = persisted({ handoffStage, handoffOperationId: 'op-handoff' }) + expect(normalizeLegacyHandoffLease(lease)).toEqual({ + ...lease, + handoffStage: 'recovering' + }) + expect(leaseCarriesLegacyHandoffValues(lease)).toBe(true) + } + ) + + it.each(CLAIMS)( + 'turns a recorded terminal owner (%s) into a conflicted native claim', + (claim) => { + const lease = persisted({ runtimeKind: 'tui', claimStatus: claim }) + expect(normalizeLegacyHandoffLease(lease)).toEqual({ + ...lease, + runtimeKind: 'native', + claimStatus: 'conflicted' + }) + } + ) + + it.each(CLAIMS)('changes only the kind of a terminal lease naming no process (%s)', (claim) => { + const lease = persisted({ runtimeKind: 'tui', claimStatus: claim, ownerProcess: null }) + expect(normalizeLegacyHandoffLease(lease)).toEqual({ ...lease, runtimeKind: 'native' }) + expect(leaseCarriesLegacyHandoffValues(lease)).toBe(true) + }) + + it('maps a terminal owner mid return trip on both fields', () => { + const lease = persisted({ runtimeKind: 'tui', handoffStage: 'old-owner-stopped' }) + expect(normalizeLegacyHandoffLease(lease)).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering', + claimStatus: 'conflicted' + }) + }) + + it('reports whether a record needed normalizing', () => { + const record = agentSessionRecordFixture() + expect(normalizeLegacyHandoffRecord(record)).toEqual({ record, normalized: false }) + const legacy = { ...record, lease: persisted({ runtimeKind: 'tui' }) } + expect(normalizeLegacyHandoffRecord(legacy)).toEqual({ + record: { ...record, lease: { ...record.lease, claimStatus: 'conflicted' } }, + normalized: true + }) + }) +}) diff --git a/src/shared/agent-session-legacy-handoff-lease.ts b/src/shared/agent-session-legacy-handoff-lease.ts new file mode 100644 index 000000000000..af2216d0ab3a --- /dev/null +++ b/src/shared/agent-session-legacy-handoff-lease.ts @@ -0,0 +1,89 @@ +/** + * Lease values only the removed terminal handoff wrote. + * + * Records an older build persisted can still carry a terminal owner (`runtimeKind: 'tui'`) or a + * handoff stage (`preparing`, `old-owner-stopped`). They are accepted on disk and mapped here, once, + * at decode, so no in-memory lease holds a value nothing in this build produces. + */ + +import type { + AgentSessionHandoffStage, + AgentSessionLease, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from './agent-session-record' + +type LegacyHandoffRuntimeKind = 'tui' +type LegacyHandoffStage = 'preparing' | 'old-owner-stopped' + +export type PersistedAgentSessionRuntimeKind = + | AgentSessionOwnerRuntimeKind + | LegacyHandoffRuntimeKind +export type PersistedAgentSessionHandoffStage = AgentSessionHandoffStage | LegacyHandoffStage + +/** A lease as it may appear on disk. */ +export type PersistedAgentSessionLease = Omit & { + runtimeKind: PersistedAgentSessionRuntimeKind + handoffStage: PersistedAgentSessionHandoffStage | null +} + +export type PersistedAgentSessionRecord = Omit & { + lease: PersistedAgentSessionLease +} + +export function isPersistedAgentSessionRuntimeKind( + value: unknown +): value is PersistedAgentSessionRuntimeKind { + return value === 'native' || value === 'tui' +} + +export function isPersistedAgentSessionHandoffStage( + value: unknown +): value is PersistedAgentSessionHandoffStage { + return ( + value === 'preparing' || + value === 'old-owner-stopped' || + value === 'new-owner-proving' || + value === 'recovering' || + value === 'manual-recovery' + ) +} + +export function leaseCarriesLegacyHandoffValues(lease: PersistedAgentSessionLease): boolean { + return ( + lease.runtimeKind === 'tui' || + lease.handoffStage === 'preparing' || + lease.handoffStage === 'old-owner-stopped' + ) +} + +/** Identity for every lease this build writes. */ +export function normalizeLegacyHandoffLease(lease: PersistedAgentSessionLease): AgentSessionLease { + const { runtimeKind, handoffStage } = lease + const stage = + handoffStage === 'preparing' || handoffStage === 'old-owner-stopped' + ? 'recovering' + : handoffStage + if (runtimeKind === 'native') { + return { ...lease, runtimeKind, handoffStage: stage } + } + return { + ...lease, + runtimeKind: 'native', + handoffStage: stage, + // Why: a recorded terminal is the user's foreground agent. `conflicted` is the claim every + // build probes but never stops; a plain native owner would be stopped by restart recovery. + claimStatus: lease.ownerProcess === null ? lease.claimStatus : 'conflicted' + } +} + +/** The in-memory record, plus whether decode changed anything the store must write back. */ +export function normalizeLegacyHandoffRecord(record: PersistedAgentSessionRecord): { + record: AgentSessionRecord + normalized: boolean +} { + return { + record: { ...record, lease: normalizeLegacyHandoffLease(record.lease) }, + normalized: leaseCarriesLegacyHandoffValues(record.lease) + } +} diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 97c3d10b0aed..83fdb65e9aee 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -134,7 +134,7 @@ export function admitAgentSessionMutation(input: { /** Why the single admission oracle said no, mapped to what the client can do * about it. The predicate itself is never re-implemented here. */ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { - if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) { + if (agentSessionLeaseAdmitsWriter(lease)) { return null } if (lease.unreconciled) { diff --git a/src/shared/agent-session-record.test-fixture.ts b/src/shared/agent-session-record.test-fixture.ts index 65da67c571ea..3e2270d368b1 100644 --- a/src/shared/agent-session-record.test-fixture.ts +++ b/src/shared/agent-session-record.test-fixture.ts @@ -10,21 +10,21 @@ const OWNER_PROCESS = { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, - spawnToken: 'spawn-tui' + spawnToken: 'spawn-owner' } -/** A proven-live TUI owner: the only lease state that admits a PTY write. */ +/** A proven-live owner: the only lease state that admits a write. */ export function agentSessionLeaseFixture( overrides: Partial = {} ): AgentSessionLease { return { sessionId: 'session-alpha-1', - runtimeKind: 'tui', + runtimeKind: 'native', runtimeFence: 7, handoffStage: null, provenHandleLinkId: 'link-1', ownerProcess: OWNER_PROCESS, - reservedSpawnToken: 'spawn-tui', + reservedSpawnToken: 'spawn-owner', leaseDeadlineAt: 60_000, lastRenewedAt: 30_000, handoffOperationId: null, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 8763674df1cd..6cfe07e70b48 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -2,6 +2,12 @@ import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './age import { isAgentSessionLaunchArgs } from './agent-session-launch-args' import { isAgentSessionSurfaceTabId } from './agent-session-surface-tab-id' import { isAgentSessionConversationName } from './agent-session-conversation-name' +import { + isPersistedAgentSessionHandoffStage, + isPersistedAgentSessionRuntimeKind, + type PersistedAgentSessionLease, + type PersistedAgentSessionRecord +} from './agent-session-legacy-handoff-lease' /** * Durable agent-session record and its single-writer lease. * @@ -51,19 +57,12 @@ export type AgentSessionLaunchEnv = Record /** Provider CLI arguments captured by the host when the session is created. */ export type AgentSessionLaunchArgs = string[] -/** `tui` is read-only history: only the removed terminal handoff wrote it, and restart recovery - * waits a recorded terminal owner out rather than adopting or stopping it. */ -export type AgentSessionOwnerRuntimeKind = 'native' | 'tui' +/** Still persisted because older builds read it. The removed terminal handoff's `tui` is mapped + * away at decode (agent-session-legacy-handoff-lease). */ +export type AgentSessionOwnerRuntimeKind = 'native' -/** The acquisition stage. `preparing` and `old-owner-stopped` were written only by the removed - * terminal handoff and stay here so older records load; restart reconciliation and settlement - * retry overwrite them like any other stage. */ -export type AgentSessionHandoffStage = - | 'preparing' - | 'old-owner-stopped' - | 'new-owner-proving' - | 'recovering' - | 'manual-recovery' +/** The acquisition stage. The removed terminal handoff's stages are mapped away at decode. */ +export type AgentSessionHandoffStage = 'new-owner-proving' | 'recovering' | 'manual-recovery' /** * PID-reuse-safe process identity. `spawnToken` is the only element available on every platform: @@ -297,22 +296,17 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath ) } -function isAgentSessionLease(value: unknown): value is AgentSessionLease { +function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSessionLease { if (typeof value !== 'object' || value === null) { return false } const lease = value as Partial return ( isAgentSessionId(lease.sessionId) && - (lease.runtimeKind === 'native' || lease.runtimeKind === 'tui') && + isPersistedAgentSessionRuntimeKind(lease.runtimeKind) && Number.isSafeInteger(lease.runtimeFence) && (lease.runtimeFence as number) >= 0 && - (lease.handoffStage === null || - lease.handoffStage === 'preparing' || - lease.handoffStage === 'old-owner-stopped' || - lease.handoffStage === 'new-owner-proving' || - lease.handoffStage === 'recovering' || - lease.handoffStage === 'manual-recovery') && + (lease.handoffStage === null || isPersistedAgentSessionHandoffStage(lease.handoffStage)) && (lease.provenHandleLinkId === null || isBoundedString(lease.provenHandleLinkId, 128)) && (lease.ownerProcess === null || isAgentSessionProcessIdentity(lease.ownerProcess)) && (lease.reservedSpawnToken === null || @@ -340,7 +334,11 @@ function isAgentSessionLease(value: unknown): value is AgentSessionLease { ) } -export function isAgentSessionRecord(value: unknown): value is AgentSessionRecord { +/** The on-disk shape, which still admits the removed terminal handoff's lease values. Decode + * through `normalizeLegacyHandoffRecord` before anything reads the lease. */ +export function isPersistedAgentSessionRecord( + value: unknown +): value is PersistedAgentSessionRecord { if (typeof value !== 'object' || value === null) { return false } @@ -361,7 +359,7 @@ export function isAgentSessionRecord(value: unknown): value is AgentSessionRecor (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && (record.surfaceTabId === undefined || isAgentSessionSurfaceTabId(record.surfaceTabId)) && !Object.hasOwn(record, 'launchEnv') && - isAgentSessionLease(record.lease) && + isPersistedAgentSessionLease(record.lease) && record.lease.sessionId === record.sessionId && Number.isSafeInteger(record.createdAt) && Number.isSafeInteger(record.updatedAt) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 468645333157..38849ab0f05c 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -28,7 +28,6 @@ import { agentSessionScopeKey, type AgentSessionExecutionLocation, type AgentSessionHandoffStage, - type AgentSessionOwnerRuntimeKind, type AgentSessionRecord } from './agent-session-record' import type { AgentProviderSessionMetadata } from './agent-session-resume' @@ -37,7 +36,7 @@ import type { StructuredAgentSessionProjectedStatus } from './structured-agent-s /** `agentSession.handoffStatus`. Named for the removed terminal handoff; released desktop clients * still read `owner`. Clients parse the reply as unknown, since older hosts sent more fields. */ export type AgentSessionHandoffStatus = { - owner: AgentSessionOwnerRuntimeKind | 'none' + owner: 'native' | 'none' direction: 'to-native' | null phase: 'idle' | 'switching' | 'failed' stage: AgentSessionHandoffStage | null From 54ce5fca0d2e2b119642a2e71c2fc37078f062a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:58:00 -0700 Subject: [PATCH 015/101] refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. --- .../agent-session-wire/structured-agent-session-attach.ts | 1 - .../structured-agent-session-claude-root-exit.test.ts | 1 - .../structured-agent-session-lease-renewer.test.ts | 1 - .../structured-agent-session-recovery-resolution.test.ts | 1 - src/main/runtime/agent-session-backup-recovery.test.ts | 2 -- .../runtime/agent-session-conversation-name-store.test.ts | 1 - src/main/runtime/agent-session-launch-env-admission.test.ts | 1 - src/main/runtime/agent-session-launch-env-backfill.test.ts | 1 - src/main/runtime/agent-session-lease-renewal.test.ts | 1 - src/main/runtime/agent-session-lease-transitions.ts | 4 +--- src/main/runtime/agent-session-record-options.test.ts | 1 - .../agent-session-record-store-legacy-handoff-lease.test.ts | 1 - src/main/runtime/agent-session-record-store-security.test.ts | 1 - .../runtime/agent-session-record-store-surface-tab-id.test.ts | 1 - src/main/runtime/agent-session-record-store.test.ts | 1 - .../runtime/agent-session-record-unsupported-schema.test.ts | 1 - src/main/runtime/agent-session-reservation-admission.test.ts | 1 - src/main/runtime/agent-session-reservation-admission.ts | 4 +--- .../runtime/agent-session-unreadable-record-salvage.test.ts | 1 - .../agent-session-unsaved-creation-supersession.test.ts | 1 - 20 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 38595d4e6eb2..2e42ffe9893d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -324,7 +324,6 @@ export function reserveRequestFor(input: { : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), - runtimeKind: params.runtimeKind, ...(params.adopt ? { // Fence 1 is a new record's first, and the owner probe requires the head link to carry diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index d83ca49a79c5..c449711e7453 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -43,7 +43,6 @@ describe('Claude root-exit eviction', () => { }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-1', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index f8323d40fe11..f969e3c0bbf7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -26,7 +26,6 @@ async function liveStore(): Promise { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: root }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-renewal', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 105521bbf985..b647ed018838 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -48,7 +48,6 @@ async function reserve(store: AgentSessionRecordStore) { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-recovery', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts index 02866a49fb3b..3c6ac2dcecf8 100644 --- a/src/main/runtime/agent-session-backup-recovery.test.ts +++ b/src/main/runtime/agent-session-backup-recovery.test.ts @@ -27,7 +27,6 @@ async function seedLiveSession(sessionId: string): Promise { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'seed-live', claimKeyId: 'key-1', @@ -101,7 +100,6 @@ async function seedSession(sessionId: string): Promise { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'seed', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-conversation-name-store.test.ts b/src/main/runtime/agent-session-conversation-name-store.test.ts index ce5d3fd26217..7310c3fff89c 100644 --- a/src/main/runtime/agent-session-conversation-name-store.test.ts +++ b/src/main/runtime/agent-session-conversation-name-store.test.ts @@ -30,7 +30,6 @@ const reserveRequest = (): AgentSessionReserveRequest => ({ location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-launch-env-admission.test.ts b/src/main/runtime/agent-session-launch-env-admission.test.ts index 97b0f95f2b6a..51b2fe64dafb 100644 --- a/src/main/runtime/agent-session-launch-env-admission.test.ts +++ b/src/main/runtime/agent-session-launch-env-admission.test.ts @@ -20,7 +20,6 @@ function request(overrides: Partial = {}): AgentSess }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-launch-env-backfill.test.ts b/src/main/runtime/agent-session-launch-env-backfill.test.ts index c95705f2aa5c..50cf9e6b35ca 100644 --- a/src/main/runtime/agent-session-launch-env-backfill.test.ts +++ b/src/main/runtime/agent-session-launch-env-backfill.test.ts @@ -20,7 +20,6 @@ function request(overrides: Partial = {}): AgentSess }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts index 0c1c88db0031..b2788f816029 100644 --- a/src/main/runtime/agent-session-lease-renewal.test.ts +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -27,7 +27,6 @@ async function establishOwner( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: directory }, - runtimeKind: 'native', expectedFence: null, spawnToken, claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 92c0ae274656..580fbeda255e 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -19,13 +19,11 @@ import { import type { AgentSessionJournalCheckpoint, AgentSessionLease, - AgentSessionOwnerRuntimeKind, AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' export type AgentSessionReservation = { - runtimeKind: AgentSessionOwnerRuntimeKind spawnToken: string claimKeyId: string handoffOperationId: string | null @@ -76,7 +74,7 @@ export function reserveAgentSessionOwner(args: { disposition: 'reserved', record: withLease(record, { ...record.lease, - runtimeKind: reservation.runtimeKind, + runtimeKind: 'native', runtimeFence: decision.nextFence, // Why: a reserved owner is not yet a writer; it may only talk to the provider to prove resume. handoffStage: 'new-owner-proving', diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index 5795763d96c8..5ba7c41cffcc 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -57,7 +57,6 @@ it('persists resumed provider options atomically with owner proof', async () => }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-options', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts index 997e32fb7cb9..77d947662129 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -44,7 +44,6 @@ async function persistLiveOwner(): Promise { }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-record-store-security.test.ts b/src/main/runtime/agent-session-record-store-security.test.ts index f70145cf5a23..63ee368f8737 100644 --- a/src/main/runtime/agent-session-record-store-security.test.ts +++ b/src/main/runtime/agent-session-record-store-security.test.ts @@ -20,7 +20,6 @@ function reserveRequest(): AgentSessionReserveRequest { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/created' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-created', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts b/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts index 20ee4b402fdf..f9603e7a86bc 100644 --- a/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts +++ b/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts @@ -48,7 +48,6 @@ function reserveRequest( location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index d3525de2de90..65d689503c2a 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -57,7 +57,6 @@ function reserveRequest( location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-record-unsupported-schema.test.ts b/src/main/runtime/agent-session-record-unsupported-schema.test.ts index 1f807870682f..dd0a9dcd28d7 100644 --- a/src/main/runtime/agent-session-record-unsupported-schema.test.ts +++ b/src/main/runtime/agent-session-record-unsupported-schema.test.ts @@ -25,7 +25,6 @@ function reserveRequest(): AgentSessionReserveRequest { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-new', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 30d54a108c9c..83e9c6100567 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -50,7 +50,6 @@ function reserveRequest( location: LOCATION, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index d7a84f6ef690..4bd3bf9b4dbc 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -62,7 +62,6 @@ export type AgentSessionReserveRequest = { /** Set only when this create adopts an existing provider conversation. Seeds the handle chain so * the adapter resumes; without it a new record has never proved a thread and starts a fresh one. */ adoptedHandleLink?: AgentSessionProviderHandleLink - runtimeKind: AgentSessionReservation['runtimeKind'] /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ expectedFence: number | null /** A supplier is invoked only when this operation wins a new reservation. */ @@ -154,7 +153,6 @@ export function applyAgentSessionReservation( throw new Error('agent_session_options_invalid') } const reservation: AgentSessionReservation = { - runtimeKind: request.runtimeKind, spawnToken: typeof request.spawnToken === 'function' ? request.spawnToken() : request.spawnToken, claimKeyId: request.claimKeyId, @@ -283,7 +281,7 @@ function createAgentSessionRecord( updatedAt: request.now, lease: { sessionId: request.sessionId, - runtimeKind: reservation.runtimeKind, + runtimeKind: 'native', // Why: fence 1 is the first reservation; 0 is reserved for "no owner has ever existed". runtimeFence: 1, handoffStage: 'new-owner-proving', diff --git a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts index 0d44d88d82bc..cde4091d7ebf 100644 --- a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts +++ b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts @@ -34,7 +34,6 @@ function reserveRequest( }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - runtimeKind: 'native', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts index ec2a1e82e92b..50d27baa57ac 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -26,7 +26,6 @@ function reserveRequest( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', expectedFence: null, spawnToken: `spawn-${operations}`, claimKeyId: 'key-1', From 713b126feb57dece532ce6c3107d1d1d014dbd6b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:01:14 -0700 Subject: [PATCH 016/101] test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. --- .../agent-session-record-store-legacy-handoff-lease.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts index 77d947662129..f0d13ba9794c 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -75,6 +75,8 @@ async function persistLiveOwner(): Promise { }, now: NOW }) + // Commits the visibility index, so a later hide changes nothing but a pending rewrite. + await store.setSessionTabVisibility(SESSION, false) } describe('a lease the removed terminal handoff wrote', () => { From 44d797728c7a4c6e7d87f3fbfe0abef2d748471e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:01:50 -0700 Subject: [PATCH 017/101] fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. --- docs/reference/remote-wire-compatibility.md | 6 +- ...ession-history-forward-read-budget.test.ts | 6 +- ...structured-agent-session-attach-context.ts | 9 +-- ...ured-agent-session-attach-orchestration.ts | 15 +--- ...d-agent-session-background-task-channel.ts | 3 +- ...tructured-agent-session-client-delivery.ts | 1 + ...-agent-session-command-publication.test.ts | 14 ++-- .../structured-agent-session-host-types.ts | 4 - .../structured-agent-session-host.ts | 3 +- ...ctured-agent-session-mutation-admission.ts | 19 ++--- ...ctured-agent-session-refusal-retry.test.ts | 21 ++--- .../structured-agent-session-rewind.test.ts | 8 +- ...red-agent-session-send-preparation.test.ts | 72 ++++++++++++++++- ...ructured-agent-session-send-preparation.ts | 26 +------ .../structured-agent-session-send.test.ts | 39 ++-------- ...ructured-agent-session-subscribers.test.ts | 77 ++++++++++++------- .../structured-agent-session-subscribers.ts | 43 +++++------ ...tured-agent-session-wire-admission.test.ts | 13 ++-- .../structured-conversation-command.test.ts | 9 +-- .../agent-session-mutation-envelope.test.ts | 19 +++-- src/shared/agent-session-mutation-envelope.ts | 27 ++----- ...ss-version-agent-session-wire.unit.test.ts | 8 +- 22 files changed, 215 insertions(+), 227 deletions(-) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index d72f93770c5d..b2bcb3c40f4e 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -181,8 +181,10 @@ hand-written. It covers the three skews that surface can fail on: - a new client against the old dispatcher always gets an answer rather than silence, and `method_not_found` for every method that release does not register, so the absence is visible during negotiation instead of by calling; -- a cursor survives a host restart: the client's fence is refused as stale with the live - one attached, and resuming from the held cursor replays only what it missed. +- a cursor survives a host restart: a reattach at the client's fence is refused as stale + with the live one attached, a write still carrying that fence is delivered (writes are + named by their target, and every released client still sends a fence), and resuming from + the held cursor replays only what it missed. Run it with: diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index 5a9a6f20801c..f240d65273b1 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -105,11 +105,10 @@ describe('forward history SQL read budget', () => { const journal = await seedJournal(count) const { returnedRows, parse } = observeForwardReads() const events: AgentSessionSubscribeEvent[] = [] - new AgentSessionSubscribers().open({ + new AgentSessionSubscribers({ readFence: () => 1 }).open({ id: 'reader', sessionId: identity.sessionId, journal, - fence: 1, cursor: { epoch: journal.epoch, sequence: 1 }, emit: (event) => events.push(event) }) @@ -126,11 +125,10 @@ describe('forward history SQL read budget', () => { const journal = await seedJournal(2_000) const render = vi.spyOn(journalReducer, 'renderJournalState') const events: AgentSessionSubscribeEvent[] = [] - new AgentSessionSubscribers().open({ + new AgentSessionSubscribers({ readFence: () => 1 }).open({ id: 'reader', sessionId: identity.sessionId, journal, - fence: 1, cursor: { epoch: journal.epoch, sequence: 1 }, emit: (event) => events.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 4b9b46716cb2..1464ae5ae6ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -21,13 +21,8 @@ export type StructuredAgentSessionAttachContext = { runtimeState: StructuredAgentSessionHostRuntimeState sessions: Map subscribers: { - reset: ( - sessionId: string, - journal: AgentSessionJournal, - reset: AgentJournalResetReason, - fence: number - ) => void - snapshot: (sessionId: string, journal: AgentSessionJournal, fence: number) => void + reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void + snapshot: (sessionId: string, journal: AgentSessionJournal) => void publish: ( sessionId: string, journal: AgentSessionJournal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 5482826d0725..aba50a33ff42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -21,7 +21,6 @@ import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' -import { isResumableStructuredAgentSessionRecord } from './structured-agent-session-resume-eligibility' import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' @@ -135,13 +134,6 @@ async function runAttach( // attach makes it the session's; any other exit closes it with whatever the child queued. const attemptSink = context.runtimeState.mintEventSink(sessionId) let attemptSinkAdopted = false - // A lease handed back cleanly is what a resume replaces. A writer current as of that owner is - // rebased onto the fence this attach publishes, since the restart is the only thing that moved it. - const released = context.deps.store.getRecord(sessionId) - const resumedFromFence = - released && isResumableStructuredAgentSessionRecord(released) - ? released.lease.runtimeFence - : undefined const attached = stampFailedCreateOwnerVerdict( context.deps.store, callerKey, @@ -227,8 +219,7 @@ async function runAttach( providerChildPhase: acquiredOwner ? providerChildPhase : (previous?.providerChildPhase ?? 'ready'), - acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null, - resumedFromFence: acquiredOwner ? resumedFromFence : previous?.resumedFromFence + acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null }) await recoverStructuredRewind( context.deps.store, @@ -240,9 +231,9 @@ async function runAttach( ) await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) if (attached.recovery) { - context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) + context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset) } else if (previousFence !== undefined && previousFence !== fence) { - context.subscribers.snapshot(sessionId, attached.journal, fence) + context.subscribers.snapshot(sessionId, attached.journal) } else { context.subscribers.publish(sessionId, attached.journal) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 5d922d723503..1fbc73e4afa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -48,7 +48,6 @@ export class StructuredAgentSessionBackgroundTaskChannel { return this.subscribers.open({ ...input, journal: session.journal, - fence: this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) }) } @@ -57,7 +56,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { const session = this.sessions.get(sessionId) const state = publishedState !== undefined ? publishedState : this.state(sessionId) if (session && state !== undefined) { - this.subscribers.backgroundTasks(sessionId, state, session.fence) + this.subscribers.backgroundTasks(sessionId, state) this.onPublished(sessionId) } } 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 7681e0f34362..34dc46f23b4a 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 @@ -36,6 +36,7 @@ export class StructuredAgentSessionClientDelivery { ) this.waitForSendSettlement = this.sendSettlement.wait this.subscribers = new AgentSessionSubscribers({ + readFence: (sessionId) => deps().store.getRecord(sessionId)?.lease.runtimeFence ?? 0, readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId), onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index b9b7021e854d..80c89f48ce27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -65,23 +65,25 @@ it('delivers catalog changes through existing frames without resending them on o let commands: AgentSessionSlashCommand[] | undefined = [ { name: 'loaded', kind: 'command', kindUnspecified: true } ] - const subscribers = new AgentSessionSubscribers({ readCommands: () => commands }) + const subscribers = new AgentSessionSubscribers({ + readFence: () => 7, + readCommands: () => commands + }) const close = subscribers.open({ id: 'one', sessionId, journal, - fence: 7, emit: coalescer.push }) expect(state.commands).toEqual(commands) for (let i = 0; i < 25; i++) { - subscribers.backgroundTasks(sessionId, null, 7) + subscribers.backgroundTasks(sessionId, null) } coalescer.flush() expect(events.filter((event) => 'commands' in event)).toHaveLength(1) commands = [] subscribers.publish(sessionId, journal) - subscribers.backgroundTasks(sessionId, null, 7) + subscribers.backgroundTasks(sessionId, null) coalescer.flush() expect(state.commands).toEqual([]) expect(events.filter((event) => 'commands' in event)).toHaveLength(2) @@ -96,12 +98,11 @@ it('delivers catalog changes through existing frames without resending them on o sessionId, journal, cursor: journal.cursor(), - fence: 8, emit: coalescer.push }) coalescer.flush() expect(state.commands).toEqual(commands) - subscribers.reset(sessionId, journal, 'epoch_changed', 8) + subscribers.reset(sessionId, journal, 'epoch_changed') expect(state.commands).toEqual(commands) commands = undefined subscribers.open({ @@ -109,7 +110,6 @@ it('delivers catalog changes through existing frames without resending them on o sessionId, journal, cursor: journal.cursor(), - fence: 9, emit: coalescer.push }) coalescer.flush() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 574ced3cc383..5db65f320ac0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -44,10 +44,6 @@ export type StructuredAgentSessionHostSession = { owesProviderChildWindDown?: boolean /** Exact adapter acquisition behind `hasProviderChild`; retained after exit to fence recovery. */ acquisitionGeneration: string | null - /** The fence of the released owner this child replaced, when it was resumed into a lease handed - * back cleanly. A writer current as of that owner is admitted at `fence`: the restart is the - * only thing that moved it. Absent for a create or a journal restored for reading. */ - resumedFromFence?: number } export type StructuredAgentSessionHostDeps = { 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 ba508b67bcf4..b13a042a85fa 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 @@ -116,8 +116,7 @@ export class StructuredAgentSessionHost { store: deps.store, sessions: this.sessions, flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId), - publishFence: (sessionId, session) => - this.subscribers.snapshot(sessionId, session.journal, session.fence), + publishFence: (sessionId, session) => this.subscribers.snapshot(sessionId, session.journal), publishStatus: this.clientDelivery.publishStatusAndSettlement, hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), restartReleaseGrace: (sessionId) => this.holds.renew(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 040014506219..62be89001596 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -5,8 +5,8 @@ // // Admission is two-phase for a call that brings a `prepareSession`. The ledger's // answer comes first and places nothing; a call it will admit may then give the -// session an owner, and only after that are the row placed and the lease and -// fence checked — against the lease as it stands once the owner is there. +// session an owner, and only after that are the row placed and the lease +// checked — against the lease as it stands once the owner is there. import { admitAgentSessionMutation, @@ -44,7 +44,7 @@ export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { } export type AgentSessionMutationSessionPreparation = - | { ok: true; envelope: AgentSessionMutationEnvelope } + | { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } export type AgentSessionMutationRequest = { @@ -56,8 +56,7 @@ export type AgentSessionMutationRequest = { /** Journal of the attached session, read after `prepareSession`; absent when this host holds none. */ journal: () => AgentSessionJournal | undefined /** Between the ledger's answer and the lease check, for a call that may first have to make the - * session ready for itself. Answers with the envelope to admit — the caller's, or one moved - * onto a fence the preparation itself published — or with the refusal that ends the call. */ + * session ready for itself. Answers with the refusal that ends the call, if any. */ prepareSession?: ( ledger: Exclude, record: AgentSessionRecord @@ -71,8 +70,7 @@ export type AgentSessionMutationRequest = { export async function admitAndRunAgentSessionMutation( request: AgentSessionMutationRequest ): Promise> { - const { plan } = request - let { envelope } = request + const { plan, envelope } = request const hostFingerprint = computeAgentSessionPayloadFingerprint({ method: plan.method, sessionId: envelope.sessionId, @@ -98,7 +96,6 @@ export async function admitAndRunAgentSessionMutation( if (!prepared.ok) { return prepared } - envelope = prepared.envelope } } const journal = request.journal() @@ -137,9 +134,9 @@ export async function admitAndRunAgentSessionMutation( return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } } // Nothing durable landed, so this id is about to run for the first time. A - // refused call leaves its ledger row behind, and replaying past the lease and - // the fence would let a resend act under an owner that has since changed — so - // a first run pays the full admission price either way. + // refused call leaves its ledger row behind, and replaying past the lease + // would let a resend act with no live owner — so a first run pays the full + // admission price either way. const rerun = admitAgentSessionMutation({ envelope, hostFingerprint, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 566156e096f6..87f4f9e3d65e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -231,7 +231,10 @@ const UNREACHABLE = new Set([ // Send reconstructs doubt from its global tombstone instead of refusing it. 'agentSession.send:agent_session_operation_unknown', // Only a send restarts a lost owner. - 'agentSession.setOption:agent_session_owner_restart_failed' + 'agentSession.setOption:agent_session_owner_restart_failed', + // A write names its target, not an owner generation; only an attach compares fences. + 'agentSession.setOption:agent_session_checkpoint_stale', + 'agentSession.send:agent_session_checkpoint_stale' ]) describe('agentSessionRefusalOperationState host oracle', () => { @@ -242,20 +245,8 @@ describe('agentSessionRefusalOperationState host oracle', () => { const stale = await createHarness() for (const method of METHODS) { - const spec = { - method, - operationId: operationId(), - expectedRuntimeFence: 99 - } - record( - await assertHostAgreement(stale, spec, 'agent_session_checkpoint_stale', async () => ({ - harness: stale, - spec: { - ...spec, - expectedRuntimeFence: stale.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - } - })) - ) + const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } + await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) } expect(stale.setOption).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 19ae18dd5f44..b82248cb06ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -229,14 +229,8 @@ describe('host rewind', () => { expect(recoverRewind).toHaveBeenCalledTimes(2) expect(rewind).toHaveBeenCalledTimes(1) }) - it('fences stale owners and the second of two concurrent rewinds', async () => { + it('refuses the second of two concurrent rewinds by the epoch it targets', async () => { const target = await seed() - const stale = params(target) - stale.envelope.expectedRuntimeFence++ - expect(await host.rewind(caller, stale)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) let finish!: () => void rewind.mockImplementation( () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f48af3b4d4b3..e5866241105e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -6,7 +6,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -676,3 +679,70 @@ describe('a send with no live owner', () => { expect(acquire).not.toHaveBeenCalled() }) }) + +// A pane keeps the fence of the last frame it read. An idle release and the restart after it +// each move the lease, so that fence can be several generations behind the one a write lands on. +describe('a write fenced to an owner the pane has not seen replaced', () => { + it('delivers a send fenced to the owner an idle release retired', async () => { + const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const frames: AgentSessionSubscribeEvent[] = [] + host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) + await loseOwner() + const params = sendParams('after the release') + params.envelope.expectedRuntimeFence = seenFence + + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + + expect(acquire).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledOnce() + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(fence).toBeGreaterThan(seenFence + 1) + // The pane learns the fence the host is at, not the one it subscribed under. + expect(frames.at(-1)).toMatchObject({ fence }) + }) + + it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { + await loseOwner() + const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + let claimed = () => {} + let release = () => {} + const claim = new Promise((resolve) => (claimed = resolve)) + const spawn = new Promise((resolve) => (release = resolve)) + acquire.mockImplementationOnce(async (input) => { + claimed() + await spawn + return spawnChild(input) + }) + + const first = host.send(CALLER, sendParams('starts the agent')) + await claim + const cancelFields = { turnId: 'turn-1' } + const stop = host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: lostFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: cancelFields + }) + }, + ...cancelFields + }) + const late = sendParams('typed during the start') + late.envelope.expectedRuntimeFence = lostFence - 1 + const second = host.send(CALLER, late) + release() + + expect(await first).toMatchObject({ ok: true }) + expect(await stop).toMatchObject({ ok: true, replayed: false }) + expect(await second).toMatchObject({ ok: true, replayed: false }) + expect(acquire).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index e4a332f7acf8..fa57156d05a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -12,13 +12,8 @@ // // The ledger's answer comes first, so a send it already holds a row for restarts nothing: // admission replays or refuses it whoever owns the session now, and a closed session is made -// readable for that, never given a child. Otherwise a child that dies at startup moves the fence, -// the client resends the same message against the new fence, and each replay spawns another -// child that dies the same way. -// -// Running inside the send's serialize is what makes "no child" exact and the fence bookkeeping -// simple: the owner this send (or a hold just ahead of it) replaced is the one the client was -// current as of, so the send is admitted at the fence the restart published. +// readable for that, never given a child. Otherwise each resend of a message whose child died at +// startup would spawn another child that dies the same way. // // A child that has not proven its start is still the owner: the send is admitted against it and // the adapter holds the message until startup lands, or rejects it with the child's own reason @@ -128,7 +123,7 @@ export async function prepareStructuredAgentSessionSend( if (!context.sessions.has(sessionId)) { await context.restoreReadable(sessionId) } - return { ok: true, envelope } + return { ok: true } } if (structuredAgentSessionSendNeedsOwner(context.sessions.get(sessionId), record)) { const refusal = await restartOwnerForSend(context, envelope, record) @@ -136,7 +131,7 @@ export async function prepareStructuredAgentSessionSend( return { ok: false, refusal } } } - return { ok: true, envelope: admitAtResumedFence(context.sessions.get(sessionId), envelope) } + return { ok: true } } /** One restart attempt. Answers with the refusal that ends the send, or null when the send goes @@ -229,16 +224,3 @@ async function recordFailedRestart( context.deps.onEventSinkError?.({ sessionId, error }) } } - -/** A writer current as of the owner this child replaced is current now: the restart was the only - * thing that moved the fence, whether this send ran it or one just ahead of it did. */ -function admitAtResumedFence( - session: StructuredAgentSessionHostSession | undefined, - envelope: AgentSessionMutationEnvelope -): AgentSessionMutationEnvelope { - return session?.hasProviderChild && - session.resumedFromFence !== undefined && - envelope.expectedRuntimeFence === session.resumedFromFence - ? { ...envelope, expectedRuntimeFence: session.fence } - : envelope -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f2d1ab122300..638d8a8679f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -446,24 +446,7 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('refuses a stale fence and hands back the current one', async () => { - const record = await attach() - const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope( - 'agentSession.send', - { body }, - { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } - ), - body - }) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } - }) - }) - - it('reuses a pending send admission after the client refreshes its fence', async () => { + it('admits a send fenced to another generation, and replays it by id once the fence catches up', async () => { const record = await attach() const body = hostTestMessage('add a retry') const params = { @@ -475,26 +458,14 @@ describe('send', () => { body } expect(await host.send(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) - expect( - store - .listOperationRows() - .filter((row) => row.operationId === params.envelope.clientOperationId) - ).toEqual([]) - const retry = { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: record?.lease.runtimeFence ?? 1 - } - } - expect(await host.send(CALLER, retry)).toMatchObject({ ok: true, replayed: false, value: { submission: { dispatchState: 'accepted' } } }) + const retry = { + ...params, + envelope: { ...params.envelope, expectedRuntimeFence: record?.lease.runtimeFence ?? 1 } + } expect(await host.send(CALLER, retry)).toMatchObject({ ok: true, replayed: true }) expect(dispatch).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 82f8c551642b..a5a588559cd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -46,7 +46,7 @@ describe('AgentSessionSubscribers', () => { }, journalDir: join(root, 'activity-churn-journal') }) - const subscribers = new AgentSessionSubscribers() + const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) for (let index = 0; index < MAX_RETAINED_SESSION_ACTIVITIES + 4; index += 1) { subscribers.publish(`session-${index}`, journal, { turnId: `turn-${index}`, text: 'working' }) @@ -68,11 +68,10 @@ describe('AgentSessionSubscribers', () => { }) const events: AgentSessionSubscribeEvent[] = [] - new AgentSessionSubscribers().open({ + new AgentSessionSubscribers({ readFence: () => 7 }).open({ id: 'subscriber-1', sessionId: SESSION, journal, - fence: 7, cursor: journal.cursor(), emit: (event) => events.push(event) }) @@ -107,20 +106,20 @@ describe('AgentSessionSubscribers', () => { }) let now = 1_000 const events: AgentSessionSubscribeEvent[] = [] - const subscribers = new AgentSessionSubscribers({ now: () => (now += 1) }) + const subscribers = new AgentSessionSubscribers({ readFence: () => 1, now: () => (now += 1) }) const emit = (event: AgentSessionSubscribeEvent): void => { events.push(event) } - subscribers.open({ id: 'one', sessionId: SESSION, journal, fence: 1, emit }) - subscribers.open({ id: 'two', sessionId: SESSION, journal, fence: 1, emit }) + subscribers.open({ id: 'one', sessionId: SESSION, journal, emit }) + subscribers.open({ id: 'two', sessionId: SESSION, journal, emit }) await journal.appendItem( { provider: 'orca', clientMessageId: 'clocked' }, { kind: 'status', text: 'Clocked' }, { fence: 1 } ) subscribers.publish(SESSION, journal) - subscribers.backgroundTasks(SESSION, null, 1) - subscribers.reset(SESSION, journal, 'epoch_changed', 1) + subscribers.backgroundTasks(SESSION, null) + subscribers.reset(SESSION, journal, 'epoch_changed') expect(events.map((event) => ('hostNow' in event ? event.hostNow : null))).toEqual([ 1_001, 1_002, @@ -152,12 +151,14 @@ describe('AgentSessionSubscribers', () => { }) let commands = [{ name: 'first', kind: 'skill' as const }] const events: AgentSessionSubscribeEvent[] = [] - const subscribers = new AgentSessionSubscribers({ readCommands: () => commands }) + const subscribers = new AgentSessionSubscribers({ + readFence: () => 7, + readCommands: () => commands + }) subscribers.open({ id: 'one', sessionId: SESSION, journal, - fence: 7, emit: (event) => events.push(event) }) expect(events[0]).toMatchObject({ type: 'snapshot', commands }) @@ -176,7 +177,6 @@ describe('AgentSessionSubscribers', () => { sessionId: SESSION, journal, cursor: journal.cursor(), - fence: 7, emit: (event) => events.push(event) }) expect(events[2]).toMatchObject({ type: 'batch', commands }) @@ -195,6 +195,7 @@ describe('AgentSessionSubscribers', () => { }) const published: string[] = [] const subscribers = new AgentSessionSubscribers({ + readFence: () => 1, onJournalPublished: (sessionId, published_journal) => { expect(published_journal).toBe(journal) published.push(sessionId) @@ -202,8 +203,8 @@ describe('AgentSessionSubscribers', () => { }) subscribers.publish(SESSION, journal) - subscribers.reset(SESSION, journal, 'epoch_changed', 1) - subscribers.snapshot(SESSION, journal, 1) + subscribers.reset(SESSION, journal, 'epoch_changed') + subscribers.snapshot(SESSION, journal) expect(published).toEqual([SESSION, SESSION, SESSION]) }) @@ -244,6 +245,7 @@ describe('AgentSessionSubscribers', () => { now: () => 1_000 }) const subscribers = new AgentSessionSubscribers({ + readFence: () => 1, onJournalPublished: (sessionId, published) => statusFeed.publish(sessionId, published) }) const statuses: AgentSessionStatusEvent[] = [] @@ -276,7 +278,7 @@ describe('AgentSessionSubscribers', () => { }) }) - it('publishes background lifecycle without advancing the journal and carries its fence forward', async () => { + it('publishes background lifecycle without advancing the journal', async () => { const journal = await journals.open({ identity: { sessionId: SESSION, @@ -287,13 +289,12 @@ describe('AgentSessionSubscribers', () => { }, journalDir: join(root, 'background-journal') }) - const subscribers = new AgentSessionSubscribers() + const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) const events: AgentSessionSubscribeEvent[] = [] subscribers.open({ id: 'subscriber-1', sessionId: SESSION, journal, - fence: 1, backgroundTasks: null, emit: (event) => events.push(event) }) @@ -303,26 +304,51 @@ describe('AgentSessionSubscribers', () => { state: 'monitoring' as const, tasks: [{ id: 'task-1', kind: 'command' as const, description: 'run the build' }] } - subscribers.backgroundTasks(SESSION, backgroundTasks, 2) + subscribers.backgroundTasks(SESSION, backgroundTasks) expect(journal.cursor()).toEqual(cursor) expect(events.at(-1)).toEqual({ type: 'batch', sessionId: SESSION, batch: { cursor, items: [], removedItemIds: [], submissions: [] }, - fence: 2, + fence: 1, hostNow: expect.any(Number), backgroundTasks }) + }) + + it('stamps a fence that moved without a replay on the next frame', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'moved-fence-journal') + }) + let fence = 1 + const subscribers = new AgentSessionSubscribers({ readFence: () => fence }) + const events: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + emit: (event) => events.push(event) + }) + expect(events.at(-1)).toMatchObject({ type: 'snapshot', fence: 1 }) + // An idle release and the resume after it move the lease with no snapshot in between. + fence = 3 await journal.appendItem( - { provider: 'orca', clientMessageId: 'after-background-fence' }, - { kind: 'status', text: 'After background state' }, - { fence: 2 } + { provider: 'orca', clientMessageId: 'after-release' }, + { kind: 'status', text: 'After the release' }, + { fence: 3 } ) subscribers.publish(SESSION, journal) - expect(events.at(-1)).toMatchObject({ type: 'batch', fence: 2 }) + expect(events.at(-1)).toMatchObject({ type: 'batch', fence: 3 }) }) it('publishes latest turn activity without advancing or adding journal rows', async () => { @@ -336,13 +362,12 @@ describe('AgentSessionSubscribers', () => { }, journalDir: join(root, 'activity-journal') }) - const subscribers = new AgentSessionSubscribers() + const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) const events: AgentSessionSubscribeEvent[] = [] subscribers.open({ id: 'subscriber-1', sessionId: SESSION, journal, - fence: 1, emit: (event) => events.push(event) }) const cursor = journal.cursor() @@ -368,7 +393,6 @@ describe('AgentSessionSubscribers', () => { id: 'reconnected', sessionId: SESSION, journal, - fence: 1, cursor, emit: (event) => events.push(event) }) @@ -440,13 +464,12 @@ describe('AgentSessionSubscribers', () => { journalDir }) - const subscribers = new AgentSessionSubscribers() + const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) const events: AgentSessionSubscribeEvent[] = [] subscribers.open({ id: 'subscriber-1', sessionId: SESSION, journal, - fence: 1, cursor: resumeCursor, emit: (event) => events.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index af5e231730c6..4c188d8c72d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -3,6 +3,9 @@ // Each subscriber advances independently: a client that connected two epochs // ago gets a reset while a caught-up one gets a batch from the same publish. // Nothing raw reaches a subscriber — every event carries reducer output. +// +// Every frame carries the fence read when it is sent. A per-subscriber copy went +// stale whenever the lease moved without a replay, such as an idle release. import type { AgentJournalCursor, @@ -36,11 +39,12 @@ type Subscriber = { sessionId: string emit: AgentSessionSubscriberEmit cursor: AgentJournalCursor - fence: number commands?: AgentSessionSlashCommand[] | null } export type AgentSessionSubscribersHooks = { + /** The session's current runtime fence, stamped on every frame for clients that still read it. */ + readFence: (sessionId: string) => number readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined /** Fires after publications that can change journal content. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void @@ -51,7 +55,7 @@ export class AgentSessionSubscribers { private readonly bySession = new Map>() private readonly activityBySession = new Map() - constructor(private readonly hooks: AgentSessionSubscribersHooks = {}) {} + constructor(private readonly hooks: AgentSessionSubscribersHooks) {} get retainedActivityCountForTests(): number { return this.activityBySession.size @@ -61,7 +65,6 @@ export class AgentSessionSubscribers { id: string sessionId: string journal: AgentSessionJournal - fence: number emit: AgentSessionSubscriberEmit cursor?: AgentJournalCursor backgroundTasks?: AgentSessionBackgroundTaskState | null @@ -71,8 +74,7 @@ export class AgentSessionSubscribers { id: input.id, sessionId: input.sessionId, emit: input.emit, - cursor: input.cursor ?? { epoch: liveCursor.epoch, sequence: 0 }, - fence: input.fence + cursor: input.cursor ?? { epoch: liveCursor.epoch, sequence: 0 } } const session = this.bySession.get(input.sessionId) ?? new Map() session.set(input.id, subscriber) @@ -82,12 +84,13 @@ export class AgentSessionSubscribers { if (input.cursor) { this.deliver(subscriber, input.journal, hostNow, true, input.backgroundTasks) } else { - const page = readAgentSessionHydrationPage(input.journal, input.fence) + const fence = this.hooks.readFence(input.sessionId) + const page = readAgentSessionHydrationPage(input.journal, fence) this.emit(subscriber, { type: 'snapshot', sessionId: input.sessionId, page, - fence: input.fence, + fence, hostNow, ...(input.backgroundTasks !== undefined ? { backgroundTasks: input.backgroundTasks } : {}), ...this.activityField(input.sessionId) @@ -138,28 +141,26 @@ export class AgentSessionSubscribers { sessionId: string, journal: AgentSessionJournal, reason: AgentJournalResetReason, - fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null ): void { - this.replay(sessionId, journal, fence, backgroundTasks, { type: 'reset', reset: reason }) + this.replay(sessionId, journal, backgroundTasks, { type: 'reset', reset: reason }) } snapshot( sessionId: string, journal: AgentSessionJournal, - fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null ): void { - this.replay(sessionId, journal, fence, backgroundTasks, { type: 'snapshot' }) + this.replay(sessionId, journal, backgroundTasks, { type: 'snapshot' }) } private replay( sessionId: string, journal: AgentSessionJournal, - fence: number, backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, frame: { type: 'snapshot' } | { type: 'reset'; reset: AgentJournalResetReason } ): void { + const fence = this.hooks.readFence(sessionId) const page = readAgentSessionHydrationPage(journal, fence) const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { @@ -173,17 +174,13 @@ export class AgentSessionSubscribers { ...this.activityField(sessionId) }) subscriber.cursor = page.liveCursor ?? page.window.nextCursor - subscriber.fence = fence } this.hooks.onJournalPublished?.(sessionId, journal) } - backgroundTasks( - sessionId: string, - state: AgentSessionBackgroundTaskState | null, - fence: number - ): void { + backgroundTasks(sessionId: string, state: AgentSessionBackgroundTaskState | null): void { const hostNow = this.now() + const fence = this.hooks.readFence(sessionId) for (const subscriber of this.subscribers(sessionId)) { this.emit(subscriber, { type: 'batch', @@ -193,7 +190,6 @@ export class AgentSessionSubscribers { backgroundTasks: state, hostNow }) - subscriber.fence = fence } } @@ -213,6 +209,7 @@ export class AgentSessionSubscribers { ? this.activityField(subscriber.sessionId).activity : undefined const publishedActivity = activity !== undefined ? activity : checkpointActivity + const fence = this.hooks.readFence(subscriber.sessionId) const readPage = createAgentSessionCatchUpReader(journal) while (true) { const result = readPage({ @@ -222,13 +219,13 @@ export class AgentSessionSubscribers { limit: AGENT_SESSION_HISTORY_MAX_LIMIT }) if (!result.ok) { - const page = { ...result.page, fence: subscriber.fence } + const page = { ...result.page, fence } this.emit(subscriber, { type: 'reset', sessionId: subscriber.sessionId, reset: result.reset, page, - fence: subscriber.fence, + fence, hostNow, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) @@ -247,7 +244,7 @@ export class AgentSessionSubscribers { type: 'batch', sessionId: subscriber.sessionId, batch: emptyAgentSessionBatch(page.window.nextCursor), - fence: subscriber.fence, + fence, hostNow, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) @@ -264,7 +261,7 @@ export class AgentSessionSubscribers { removedItemIds: page.removedItemIds, submissions: page.submissions }, - fence: subscriber.fence, + fence, hostNow, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 585cbd4fbbdf..82a7d683a184 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -54,21 +54,22 @@ describe('structured agent-session outbound admission', () => { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES ) - const subscribers = new AgentSessionSubscribers() + let fence = 1 + const subscribers = new AgentSessionSubscribers({ readFence: () => fence }) const initial: AgentSessionSubscribeEvent[] = [] const dispose = subscribers.open({ id: 'initial', sessionId: SESSION, journal, - fence: 1, emit: (event) => initial.push(event) }) expect(initial).toHaveLength(1) expect(initial[0]).toMatchObject({ type: 'snapshot', page: { hasOlder: true } }) expectAdmitted(initial[0]) - subscribers.backgroundTasks(SESSION, null, 2) - subscribers.snapshot(SESSION, journal, 2) + fence = 2 + subscribers.backgroundTasks(SESSION, null) + subscribers.snapshot(SESSION, journal) expect(initial.slice(1)).toHaveLength(2) initial.slice(1).forEach(expectAdmitted) @@ -77,7 +78,6 @@ describe('structured agent-session outbound admission', () => { id: 'old-epoch', sessionId: SESSION, journal, - fence: 2, cursor: { epoch: 'retired-epoch', sequence: 1 }, emit: (event) => epochReset.push(event) }) @@ -109,7 +109,7 @@ describe('structured agent-session outbound admission', () => { }) it('splits valid-cursor catch-up into admitted batch frames', () => { - const subscribers = new AgentSessionSubscribers() + const subscribers = new AgentSessionSubscribers({ readFence: () => 2 }) const catchup: AgentSessionSubscribeEvent[] = [] const firstItemSequence = journal.snapshot().items[0]!.sequence @@ -117,7 +117,6 @@ describe('structured agent-session outbound admission', () => { id: 'catchup', sessionId: SESSION, journal, - fence: 2, cursor: { epoch: journal.epoch, sequence: firstItemSequence }, emit: (event) => catchup.push(event) }) 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 a261c6dd1517..37d8d4ee83d7 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 @@ -245,14 +245,11 @@ describe('host conversation commands', () => { }) }) - it('rejects stale fences before provider execution', async () => { + it('runs a command whose fence the client has not caught up to', async () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) - expect(compact).not.toHaveBeenCalled() + expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) + expect(compact).toHaveBeenCalledTimes(1) }) it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { let finish!: (value: {}) => void diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index e929d628de54..306450f8a639 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -109,16 +109,15 @@ describe('admitAgentSessionMutation', () => { expect(admission.decision).toBe('replay') }) - it('refuses a stale fence and hands back the current one', () => { - const admission = admitAgentSessionMutation({ - ...base, - envelope: envelope({ expectedRuntimeFence: 3 }), - ledger: ADMIT('f'.repeat(64)) - }) - expect(admission).toMatchObject({ - decision: 'refused', - refusal: { code: 'agent_session_checkpoint_stale', currentFence: 4 } - }) + it('admits a writer whatever fence the client last saw', () => { + for (const expectedRuntimeFence of [3, 5, null]) { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence }), + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission, String(expectedRuntimeFence)).toMatchObject({ decision: 'admit' }) + } }) it('refuses a writer while the lease is unreconciled', () => { diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 83fdb65e9aee..fe439c20b08e 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -11,10 +11,7 @@ import type { AgentSessionOperationDecision, AgentSessionOperationRow } from './agent-session-operation-ledger' -import { - agentSessionLeaseAdmitsWriter, - isAgentSessionFenceCurrent -} from './agent-session-lease-adjudication' +import { agentSessionLeaseAdmitsWriter } from './agent-session-lease-adjudication' import type { AgentSessionLease } from './agent-session-record' import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' @@ -81,10 +78,11 @@ export type AgentSessionMutationAdmission = /** * Fixed order: fingerprint agreement, then the ledger (so a retry replays - * before anything else can refuse it), then the lease, then the fence. Putting - * the ledger ahead of the fence is deliberate — a retry that crossed an owner - * change must still return its recorded answer instead of a stale-checkpoint - * refusal the client would then resend as a second effect. + * before anything else can refuse it), then the lease. + * + * `expectedRuntimeFence` is not checked: each write names its own target (a + * turn, an item revision, an epoch) or is last-writer-wins, so an owner restart + * the client has not seen yet refuses nothing. Older hosts still check it. */ export function admitAgentSessionMutation(input: { envelope: AgentSessionMutationEnvelope @@ -115,19 +113,6 @@ export function admitAgentSessionMutation(input: { if (leaseRefusal) { return { decision: 'refused', refusal: leaseRefusal } } - if ( - envelope.expectedRuntimeFence === null || - !isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence) - ) { - return { - decision: 'refused', - refusal: { - code: 'agent_session_checkpoint_stale', - message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`, - currentFence: lease.runtimeFence - } - } - } return { decision: 'admit', row: ledger.row } } 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 d244fba1ba68..4d18e81ced6d 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 @@ -726,15 +726,17 @@ describe('cross-version structured agent sessions', () => { expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence) }) - it('refuses a write still fenced to the host generation that died', async () => { + // Every released client still sends the fence it last saw; this host names a write by its + // target and ignores that fence. Only the attach keeps comparing one, which `reattach` pins. + it('delivers a write still fenced to the host generation that died', async () => { const created = await answer('agentSession.create', createIntentParams()) await bootHost('b') const reattached = await reattach(created.fence) expect(reattached.fence).toBeGreaterThan(created.fence) expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } + ok: true, + fence: reattached.fence }) }) }) From 6dad39a1ddef96cc3bb1572302dcc1c510ae28dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:00:47 -0700 Subject: [PATCH 018/101] fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. --- .../agent-session-journal/journal-store.ts | 17 +- ...ured-agent-session-append-delivery.test.ts | 188 ++++++++++++++++++ ...ctured-agent-session-conversations.test.ts | 126 ++++++++++++ .../structured-agent-session-conversations.ts | 47 +++++ ...structured-agent-session-host-mutations.ts | 4 +- .../structured-agent-session-host.ts | 15 +- ...ured-agent-session-restart-continuation.ts | 6 +- ...tured-agent-session-restart-resume-host.ts | 1 - ...red-agent-session-restart-resume-wiring.ts | 2 +- ...ructured-agent-session-send-preparation.ts | 3 +- ...red-agent-session-surface-lifetime.test.ts | 9 +- .../structured-agent-session-thread-goal.ts | 2 - .../structured-agent-session-turns-prompt.ts | 2 - .../structured-agent-session-turns.ts | 8 +- ...ession-unanswered-dispatch-release.test.ts | 11 +- .../structured-conversation-command.ts | 4 - 16 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index ec445cb70048..568fbe4d39d3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -76,6 +76,7 @@ export class AgentSessionJournal { private readOnly = false private malformedRows = 0 private database: OpenJournalDatabase | null = null + private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly closer: JournalConnectionCloser private readonly rowWriter: JournalRowWriter @@ -111,8 +112,14 @@ export class AgentSessionJournal { this.readOnly = readOnly }, cursor: this.cursor, - adopt: (loaded) => this.adoptLoadedJournal(loaded), - commit: (row) => applyJournalRow(this.state, row), + adopt: (loaded) => { + this.adoptLoadedJournal(loaded) + this.onCommitted?.() + }, + commit: (row) => { + applyJournalRow(this.state, row) + this.onCommitted?.() + }, loaded: () => this.loaded, malformedRows: () => this.malformedRows, setMalformedRows: (count) => { @@ -167,6 +174,12 @@ export class AgentSessionJournal { return this.closer.close() } + /** Told of every durable change, epoch replacements included, so a reader learns of a write + * without its writer saying so. One listener: a later call replaces it. It must not throw. */ + observeCommits(listener: () => void): void { + this.onCommitted = listener + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts new file mode 100644 index 000000000000..aa955eeee9cc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -0,0 +1,188 @@ +// What an open chat receives, asserted at its subscriber rather than in the journal: a fresh +// subscribe re-reads the journal and hides a write that never reached the readers already open. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let generation = 0 + +/** Everything a live subscriber was sent after it opened. */ +function liveReader() { + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => events.push(event) }) + const opened = events.length + const received = () => { + const items: AgentJournalRenderItem[] = [] + const submissions: AgentJournalSubmission[] = [] + for (const event of events.slice(opened)) { + if (event.type === 'batch') { + items.push(...event.batch.items) + submissions.push(...event.batch.submissions) + } else if (event.type === 'snapshot' || event.type === 'reset') { + items.push(...event.page.items) + submissions.push(...event.page.submissions) + } + } + return { + statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), + submissions + } + } + return { received } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +function exitBeforeProof(): Promise { + return host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: `generation-${generation}`, + reason: EXIT_REASON, + cause: 'unexpected-exit', + startupUnproven: true + }) +} + +function providerSink() { + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no acquired provider sink') + } + return events +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-append-delivery-')) + resetHostTestOperationIds() + generation = 0 + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation + 1}`, + now: () => NOW + }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('an open chat receives every row its journal commits', () => { + it('shows a failed start whose lease could not be handed back', async () => { + const held = await send('hello') + const pane = liveReader() + // The exit settles the journal, then fails to release the lease: nothing moves the fence. + vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('record store busy')) + + await exitBeforeProof() + + expect(pane.received().statuses).toEqual([ + expect.stringMatching(/stopped before it finished starting: .*not signed in/) + ]) + expect(pane.received().submissions).toContainEqual( + expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) + ) + }) + + it('shows a revision the provider queued with no publish behind it', async () => { + const pane = liveReader() + const identity = { provider: 'orca' as const, clientMessageId: 'context-usage' } + const body = { kind: 'status' as const, text: 'context usage answered after the turn' } + + expect(providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }))).toEqual({ + accepted: true + }) + await host.flushStreamedEvents(SESSION) + + expect(pane.received().statuses).toEqual(['context usage answered after the turn']) + }) + + it('shows a row appended straight to the journal', async () => { + const pane = liveReader() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + + await journal.appendItem( + { provider: 'orca', clientMessageId: 'host-note' }, + { kind: 'status', text: 'written by a writer that publishes nothing' }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + ) + + expect(pane.received().statuses).toEqual(['written by a writer that publishes nothing']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts new file mode 100644 index 000000000000..f76c84d33fcb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function openJournal(name: string): Promise { + root ??= await mkdtemp(join(tmpdir(), 'orca-conversations-')) + return journals.open({ identity: IDENTITY, journalDir: join(root, name) }) +} + +function session(journal: AgentSessionJournal) { + return { + journal, + params: hostTestAttachParams(null), + fence: 0, + hasProviderChild: false, + providerChildPhase: 'ready' as const, + acquisitionGeneration: null + } +} + +function appendStatus(journal: AgentSessionJournal, text: string) { + return journal.appendItem( + { provider: 'orca', clientMessageId: text }, + { kind: 'status', text }, + { fence: 0 } + ) +} + +describe('a conversation delivers what its journal commits', () => { + it('delivers an append that no writer published', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await appendStatus(journal, 'written') + + // Already delivered when the writer's await returns, as an explicit publish would have been. + expect(deliver).toHaveBeenCalledOnce() + expect(deliver).toHaveBeenCalledWith('session-1', journal) + }) + + it('delivers nothing for a handle the conversation has replaced', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const replaced = await openJournal('a') + const current = await openJournal('b') + conversations.set('session-1', session(replaced)) + conversations.set('session-1', session(current)) + + await appendStatus(replaced, 'stale') + expect(deliver).not.toHaveBeenCalled() + + await appendStatus(current, 'live') + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', current) + }) + + it('delivers nothing once the conversation is dropped', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + conversations.delete('session-1') + + await appendStatus(journal, 'after close') + + expect(deliver).not.toHaveBeenCalled() + }) + + it('reports a reader failure without failing the durable write', async () => { + const failure = new Error('reader failed') + const onDeliveryError = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver: () => { + throw failure + }, + onDeliveryError + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await expect(appendStatus(journal, 'durable')).resolves.toMatchObject({ + itemId: expect.any(String) + }) + + expect(onDeliveryError).toHaveBeenCalledExactlyOnceWith('session-1', failure) + expect(journal.snapshot().items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: 'durable' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts new file mode 100644 index 000000000000..2f38ddf4da25 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -0,0 +1,47 @@ +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +/** + * The host's open conversations. A journal handle becomes a conversation's when it is set here, + * and from then on every change it commits reaches that conversation's readers: no writer + * publishes what it appended, so none can forget to. + * + * Delivery runs a microtask after the commit, so a reader that throws cannot fail a write that is + * already durable. A handle this map has since replaced or dropped delivers nothing. + */ +export class StructuredAgentSessionConversations extends Map< + string, + StructuredAgentSessionHostSession +> { + constructor( + private readonly delivery: { + deliver: (sessionId: string, journal: AgentSessionJournal) => void + onDeliveryError: (sessionId: string, error: unknown) => void + } + ) { + super() + } + + override set(sessionId: string, session: StructuredAgentSessionHostSession): this { + const { journal } = session + let queued = false + journal.observeCommits(() => { + if (queued) { + return + } + queued = true + queueMicrotask(() => { + queued = false + if (this.get(sessionId)?.journal !== journal) { + return + } + try { + this.delivery.deliver(sessionId, journal) + } catch (error) { + this.delivery.onDeliveryError(sessionId, error) + } + }) + }) + return super.set(sessionId, session) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 364f1e7c0dd0..906025413a9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -224,7 +224,6 @@ export async function settleStructuredAgentSessionLateDispatch( fence: session.fence } ) - context.publish(input.sessionId, session.journal) } /** @@ -240,7 +239,7 @@ export async function settleStructuredAgentSessionLateDispatch( * it never makes a send re-deliverable, because the provider may well have run it. */ export async function releaseStructuredAgentSessionUnansweredDispatches( - context: Pick, + context: Pick, input: { sessionId: string; reason: string } ): Promise { const session = context.sessions.get(input.sessionId) @@ -263,7 +262,6 @@ export async function releaseStructuredAgentSessionUnansweredDispatches( recovered: true }) } - context.publish(input.sessionId, session.journal) } /** The host's thin mutation surface. Each call re-reads the context, so a session 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 ba508b67bcf4..c6aaa8db662d 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 @@ -46,6 +46,7 @@ import type { StructuredAgentSessionTurnCompletionSubscriber } from './structure import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' import { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { createStructuredAgentSessionRestartResume, type StructuredAgentSessionRestartResume @@ -58,7 +59,10 @@ export class StructuredAgentSessionHost { () => this.mutationContext(), this ) - private readonly sessions = new Map() + private readonly sessions = new StructuredAgentSessionConversations({ + deliver: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }) + }) private readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, () => this.now(), @@ -127,10 +131,11 @@ export class StructuredAgentSessionHost { ensureProviderChild: (id, options) => this.holds.ensureProviderChild(id, options), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) - this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { - ...structuredAgentSessionRestartResumeSurfaces(this, this.now), - publish: this.subscribers.publish.bind(this.subscribers) - }) + this.restartResume = createStructuredAgentSessionRestartResume( + deps, + this.sessions, + structuredAgentSessionRestartResumeSurfaces(this, this.now) + ) this.runtimeState.startLeaseRenewal() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 58d104a651c2..0dd2337f4abe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -55,7 +55,6 @@ export type StructuredAgentSessionContinuationHost = { clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void - publish: (sessionId: string, journal: AgentSessionJournal) => void now: () => number /** Whether the marker is still an offer, with the continuation's own submission set aside. * Re-asked right before dispatch, so a newer user message refuses the send; a provider turn @@ -106,9 +105,9 @@ export function noteRestartReattachFailed( ) } -/** Writes a host-authored status note into the chat and publishes it to open panes. */ +/** Writes a host-authored status note into the chat. */ function restartNoteWriter( - host: Pick + host: Pick ): StructuredAgentSessionContinuationDeps['note'] { return async (sessionId, text, tone) => { const session = host.sessions.get(sessionId) @@ -120,7 +119,6 @@ function restartNoteWriter( { kind: 'status', text, ...(tone ? { tone } : {}) }, { fence: session.fence } ) - host.publish(sessionId, session.journal) } } 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 0770190c2f4f..29453e74652b 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 @@ -45,7 +45,6 @@ import { createStructuredAgentSessionRestartWitnesses } from './structured-agent type LiveSession = { journal: AgentSessionJournal; hasProviderChild: boolean; fence: number } export type StructuredAgentSessionRestartResumeSurfaces = { - publish: (sessionId: string, journal: AgentSessionJournal) => void revealSession: (sessionId: string) => Promise<{ readable: boolean }> hold: (sessionId: string, holderId: string) => Promise release: (sessionId: string, holderId: string) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 4494392fb485..07ec6b81599f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -41,7 +41,7 @@ type RestartResumeHostBindings = { export function structuredAgentSessionRestartResumeSurfaces( host: RestartResumeHostBindings, now: () => number -): Omit { +): StructuredAgentSessionRestartResumeSurfaces { return { revealSession: host.revealSession, hold: host.hold, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index e4a332f7acf8..e277889345fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -114,7 +114,7 @@ export function structuredAgentSessionSendNeedsOwner( type SendPreparationContext = Pick< StructuredAgentSessionMutationContext, - 'deps' | 'sessions' | 'holds' | 'restoreReadable' | 'publish' + 'deps' | 'sessions' | 'holds' | 'restoreReadable' > export async function prepareStructuredAgentSessionSend( @@ -224,7 +224,6 @@ async function recordFailedRestart( } ] }) - context.publish(sessionId, session.journal) } catch (error) { context.deps.onEventSinkError?.({ sessionId, error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index d1b17ef49b52..cb958d8364ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -246,7 +246,7 @@ describe('a chat that closes', () => { expect(host.hasSession(SESSION)).toBe(true) }) - it('releases a compatibility wait when the session is evicted', async () => { + it('answers a compatibility wait with what eviction recorded', async () => { await attach() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('pending until close') @@ -265,7 +265,12 @@ describe('a chat that closes', () => { await host.close(SESSION) - await expect(settlement).resolves.toBeUndefined() + // Eviction's settlement is a journal write, so the wait sees it rather than timing out. + await expect(settlement).resolves.toMatchObject({ + value: { + submission: { dispatchState: 'unknown', reason: 'provider_closed_before_acknowledgement' } + } + }) }) it('retries teardown after journal close loses its result', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 4e49e00f587e..3f63679d55fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -68,13 +68,11 @@ export async function performThreadGoalChange( }, { fence: ctx.fence } ) - ctx.publish() } const withdrawObjective = async (): Promise => { if (change.kind === 'set') { // Nothing was sent as a goal. await ctx.journal.appendTombstone(identity, { fence: ctx.fence }) - ctx.publish() } } let result: Awaited> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 6ac29cab0ab5..2f6a1a1f1f24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -73,7 +73,6 @@ export async function performPrompt( fence: ctx.fence } ) - ctx.publish() } }) } catch (error) { @@ -93,7 +92,6 @@ export async function performPrompt( }, { fence: ctx.fence } ) - ctx.publish() } const appended = committed.item if (!appended) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index bea393eddbbb..e8103dac62fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -42,6 +42,8 @@ export type AgentSessionTurnContext = { persistOptions: (options: Readonly>) => Promise /** Opaque client identity recorded as the resolver of a prompt. */ resolvedBy: string + /** Republishes state kept outside the journal, such as the record's options or rewind phase. + * Journal appends reach readers on their own. */ publish: () => void /** Drains provider lifecycle already accepted by the execution host. */ flushStreamedEvents: () => Promise @@ -100,7 +102,6 @@ async function appendStatus( { kind: 'status', text }, { fence: ctx.fence } ) - ctx.publish() } /** @@ -136,7 +137,6 @@ export async function performSend( } catch { return invalid('The message could not be recorded and was not sent.') } - ctx.publish() // The row just written is the send's instant on the host clock; the turn this // dispatch opens records it so the live counter never re-anchors at turn-open. @@ -161,14 +161,12 @@ export async function performSend( if (!recorded) { console.warn('[structured-agent-session] pre-dispatch refusal persistence failed') } - ctx.publish() } throw error } ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { - ctx.publish() return { ok: true, value: { @@ -206,10 +204,8 @@ export async function performSend( } catch { // Nothing further to record; the pending row is settled on the next attach. } - ctx.publish() throw error } - ctx.publish() return { ok: true, value: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index fc8440500a44..79f21a349b42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -35,9 +35,8 @@ function contextWith(submissions: AgentJournalSubmission[]) { // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and `fence`. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the host session is unreachable from this mutation. const session = { journal, fence: FENCE } as unknown as ReleaseSession - const publish = vi.fn() - const context: ReleaseContext = { sessions: new Map([['s-1', session]]), publish } - return { context, resolved, publish, journal } + const context: ReleaseContext = { sessions: new Map([['s-1', session]]) } + return { context, resolved, journal } } describe('releasing dispatches the provider can no longer answer', () => { @@ -45,7 +44,7 @@ describe('releasing dispatches the provider can no longer answer', () => { const live = [submission({})] // POSITIVE CONTROL: this is the latch being dissolved. expect(projectStructuredAgentSessionStatus([], live, FENCE)).toBe('working') - const { context, resolved, publish } = contextWith(live) + const { context, resolved } = contextWith(live) await releaseStructuredAgentSessionUnansweredDispatches(context, { sessionId: 's-1', @@ -62,14 +61,13 @@ describe('releasing dispatches the provider can no longer answer', () => { recovered: true } ]) - expect(publish).toHaveBeenCalledOnce() expect(projectStructuredAgentSessionStatus([], [submission({ recovered: true })], FENCE)).toBe( 'idle' ) }) it('never touches a pending send, whose dispatch may still be in flight', async () => { - const { context, resolved, publish } = contextWith([ + const { context, resolved } = contextWith([ submission({ clientMessageId: 'm-2', dispatchState: 'pending', reason: null }) ]) @@ -79,7 +77,6 @@ describe('releasing dispatches the provider can no longer answer', () => { }) expect(resolved).toEqual([]) - expect(publish).not.toHaveBeenCalled() }) it('leaves an already recovered unknown alone', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 022343d94070..5894defc4839 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -176,7 +176,6 @@ export function runStructuredConversationCommand( }, { fence: ctx.fence } ) - ctx.publish() try { error = ( await ctx.adapter.compact({ @@ -212,7 +211,6 @@ export function runStructuredConversationCommand( conversationCommand: matching()! } }) - ctx.publish() }) }) ).error @@ -223,7 +221,6 @@ export function runStructuredConversationCommand( { kind: 'status', text: 'Compaction completion is unconfirmed.' }, { fence: ctx.fence } ) - ctx.publish() throw cause } await ctx.journal.appendItem( @@ -231,7 +228,6 @@ export function runStructuredConversationCommand( { kind: 'status', text: error ?? 'Conversation compacted.' }, { fence: ctx.fence } ) - ctx.publish() } const completed = { ...prepared, From 97bf3bb52694f509451d9791d330806cb187d0af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:01:35 -0700 Subject: [PATCH 019/101] test(native-chat): an epoch replacement reaches the open chat --- .../structured-agent-session-conversations.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index f76c84d33fcb..f7ec795e2e10 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -68,6 +68,20 @@ describe('a conversation delivers what its journal commits', () => { expect(deliver).toHaveBeenCalledWith('session-1', journal) }) + it('delivers an epoch replacement, which readers must reload from', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await journal.replaceEpochItems('handle_forked', 0, []) + + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', journal) + }) + it('delivers nothing for a handle the conversation has replaced', async () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ From 427dc8de833b772fd0795585ad7adeddb54f0593 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:16:16 -0700 Subject: [PATCH 020/101] test(native-chat): each row reaches an open chat once, and a live handle enters only through the map --- ...ured-agent-session-append-delivery.test.ts | 38 ++++++++++++++++++- ...ctured-agent-session-conversations.test.ts | 14 +++++++ .../structured-agent-session-host-types.ts | 3 +- ...ured-agent-session-journal-handles.test.ts | 9 +++-- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index aa955eeee9cc..8b8d6fb5ee8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -52,7 +52,8 @@ function liveReader() { } return { statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), - submissions + submissions, + batches: events.slice(opened).filter((event) => event.type === 'batch').length } } return { received } @@ -186,3 +187,38 @@ describe('an open chat receives every row its journal commits', () => { expect(pane.received().statuses).toEqual(['written by a writer that publishes nothing']) }) }) + +describe('an open chat receives each row once', () => { + it('when the provider frame that wrote it also publishes', async () => { + const pane = liveReader() + const sink = providerSink() + + sink.appendItem( + { provider: 'orca', clientMessageId: 'streamed' }, + { kind: 'status', text: 'streamed row' } + ) + sink.publish() + await host.flushStreamedEvents(SESSION) + + expect(pane.received().statuses).toEqual(['streamed row']) + }) + + it('when a writer publishes the row it appended', async () => { + const pane = liveReader() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + + await journal.appendItem( + { provider: 'orca', clientMessageId: 'host-row' }, + { kind: 'status', text: 'host row' }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + ) + host['subscribers'].publish(SESSION, journal) + + expect(pane.received().statuses).toEqual(['host row']) + // The second publish found nothing past the reader's cursor, so it sent nothing at all. + expect(pane.received().batches).toBe(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index f7ec795e2e10..202ffe50ae4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -6,6 +6,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { hostTestAttachParams } from './structured-agent-session-host-test-data' const IDENTITY: AgentSessionJournalIdentity = { @@ -68,6 +69,19 @@ describe('a conversation delivers what its journal commits', () => { expect(deliver).toHaveBeenCalledWith('session-1', journal) }) + it('binds a handle set through a plain map reference', async () => { + const deliver = vi.fn() + // Collaborators hold the host's map as a plain `Map`; `set` still reaches the binding. + const sessions: Map = + new StructuredAgentSessionConversations({ deliver, onDeliveryError: vi.fn() }) + const journal = await openJournal('a') + sessions.set('session-1', session(journal)) + + await appendStatus(journal, 'through the plain map') + + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', journal) + }) + it('delivers an epoch replacement, which readers must reload from', async () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 574ced3cc383..bf73befc708b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -27,7 +27,8 @@ export type StructuredAgentSessionReveal = { } export type StructuredAgentSessionHostSession = { - journal: AgentSessionJournal + /** Readonly: a new handle enters only through the session map's `set`, which binds its delivery. */ + readonly journal: AgentSessionJournal params: AgentSessionAttachParams fence: number /** Whether THIS host generation is running the provider process behind the session. A journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index 1c9f271004c3..a21405c691aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -242,9 +242,12 @@ describe('site 11: host teardown is failure-complete', () => { const failing = sessions.get(SESSION) const closeError = new Error('close rejected') if (failing) { - failing.journal = { - close: () => Promise.reject(closeError) - } as unknown as AgentSessionJournal + sessions.set(SESSION, { + ...failing, + journal: { + close: () => Promise.reject(closeError) + } as unknown as AgentSessionJournal + }) } await expect( From 2bd44f5f7dcd622f5e0578118ac936b086c1d741 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:22:25 -0700 Subject: [PATCH 021/101] test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. --- .../agent-session-record-store-legacy-handoff-lease.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts index f0d13ba9794c..3b92eeb5fe74 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -44,6 +44,8 @@ async function persistLiveOwner(): Promise { }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + // Without one the next open backfills it, and that rewrite would mask the lease's own. + surfaceTabId: 'tab-alpha', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', From f79eb9b88f97040d6260dd2ec8b5c382267b3500 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:33:29 -0700 Subject: [PATCH 022/101] test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. --- .../worktree-agent-activation-gate.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index a5f543b1479f..49d13f87f016 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -368,6 +368,40 @@ describe('worktree agent activation gate', () => { }) }) + it.each(['present', 'unknown'] as const)( + 'does not resume OMP when a surfaced %s agent lacks conversation ownership', + async (agentOwnership) => { + const record = { + ...sleepingRecord('old-tab', DEAD_LEAF_ID, 'omp-session'), + agent: 'omp' as const + } + const ptyId = `${WORKTREE_ID}@@current-omp` + const { deps, resume, createTab } = testDeps({ + sessions: [{ ...listed(ptyId), title: 'OMP', agentOwnership }], + sleeping: [record], + surfaceOwners: new Map([ + [ + ptyId, + { + ptyId, + tabId: 'current-tab', + paneKey: `current-tab:${LIVE_LEAF_ID}` + } + ] + ]) + }) + seedExistingSurface(deps.getState(), { + tabId: 'current-tab', + leafId: LIVE_LEAF_ID, + boundPtyId: ptyId + }) + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + expect(resume).not.toHaveBeenCalled() + expect(createTab).not.toHaveBeenCalled() + expect(deps.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record) + } + ) + it('does not use an ambiguous tab binding as a live session claim', async () => { const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session') const livePtyId = `${WORKTREE_ID}@@live-agent` From 50d2e330998efd47424fd198e600f65d7b9c816f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:43:30 -0700 Subject: [PATCH 023/101] perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. --- ...ured-agent-session-append-delivery.test.ts | 7 +++ .../structured-agent-session-subscribers.ts | 44 +++++++++++++------ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 8b8d6fb5ee8d..c8c908aae70c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -192,6 +192,11 @@ describe('an open chat receives each row once', () => { it('when the provider frame that wrote it also publishes', async () => { const pane = liveReader() const sink = providerSink() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + const readSince = vi.spyOn(journal, 'readSince') sink.appendItem( { provider: 'orca', clientMessageId: 'streamed' }, @@ -201,6 +206,8 @@ describe('an open chat receives each row once', () => { await host.flushStreamedEvents(SESSION) expect(pane.received().statuses).toEqual(['streamed row']) + // The frame's publish finds the reader caught up and reads no rows: streaming stays one read. + expect(readSince).toHaveBeenCalledOnce() }) it('when a writer publishes the row it appended', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index af5e231730c6..0b54ea7c4309 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -15,6 +15,7 @@ import { type AgentSessionSubscribeEvent, type AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import { sameJournalCursor } from '../agent-session-journal/journal-cursor' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { @@ -213,6 +214,11 @@ export class AgentSessionSubscribers { ? this.activityField(subscriber.sessionId).activity : undefined const publishedActivity = activity !== undefined ? activity : checkpointActivity + // Caught up, so there are no rows to read: every publish behind a commit's own delivery. + if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) + return + } const readPage = createAgentSessionCatchUpReader(journal) while (true) { const result = readPage({ @@ -239,20 +245,7 @@ export class AgentSessionSubscribers { const page = result.page const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence if (!advanced) { - const commandsChanged = - this.hooks.readCommands !== undefined && - (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands - if (emitCheckpoint || publishedActivity !== undefined || commandsChanged) { - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: emptyAgentSessionBatch(page.window.nextCursor), - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - } + this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) return } this.emit(subscriber, { @@ -276,6 +269,29 @@ export class AgentSessionSubscribers { } } + private emitCaughtUp( + subscriber: Subscriber, + hostNow: number, + emitCheckpoint: boolean, + backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, + activity: AgentSessionTurnActivity | null | undefined + ): void { + const commandsChanged = + this.hooks.readCommands !== undefined && + (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands + if (emitCheckpoint || activity !== undefined || commandsChanged) { + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: emptyAgentSessionBatch(subscriber.cursor), + fence: subscriber.fence, + hostNow, + ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), + ...(activity !== undefined ? { activity } : {}) + }) + } + } + private now = (): number => this.hooks.now?.() ?? Date.now() private isActive = (subscriber: Subscriber): boolean => From bd72b13662683c3e0bce0830705119a32bd31127 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:48:51 -0700 Subject: [PATCH 024/101] test(native-chat): state why the teardown test's fake journal is safe to cast --- .../structured-agent-session-journal-handles.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index a21405c691aa..ef08e1c7e93d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -244,6 +244,7 @@ describe('site 11: host teardown is failure-complete', () => { if (failing) { sessions.set(SESSION, { ...failing, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close`, and this map is a plain `Map` that binds no delivery. journal: { close: () => Promise.reject(closeError) } as unknown as AgentSessionJournal From fa9b76a48a1bd0066dcad7861b4b0b9c9bed7cc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:53:11 -0700 Subject: [PATCH 025/101] docs(native-chat): say mutation admission checks only the writer lease --- src/main/runtime/agent-session-operation-admission.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index 49dbab1c90dc..e7a4165120ed 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -132,7 +132,7 @@ function mutationOperation( } } -/** Admit the ledger row and its lease/fence preconditions in one durable transaction. */ +/** Admit the ledger row and its writer-lease precondition in one durable transaction. */ export function admitAgentSessionMutationOperation( state: AgentSessionStoreState, args: AgentSessionMutationOperationAdmission From 5435973e4dc781a3755f0f74819ab55bd6ee690c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:03:34 -0700 Subject: [PATCH 026/101] docs(native-chat): drop the send rebase from comments that still described it --- .../structured-agent-session-restart-continuation.ts | 3 +-- .../structured-agent-session-send-preparation.test.ts | 2 +- ...tructured-agent-session-send-restarts-failed-start.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 58d104a651c2..85ea6e961712 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -148,8 +148,7 @@ export function restartContinuationBody(marker: AgentSessionResumeMarker): Agent } } -/** The fence is read AFTER the reconnect: reattaching mints a new one, and the pre-reconnect value - * would be refused by the mutation admission. */ +/** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ export function restartContinuationEnvelope( sessionId: string, fence: number, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index e5866241105e..563cb6609976 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -320,7 +320,7 @@ describe('a send with no live owner', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('rebases a send that arrives after the restart has already claimed the lease', async () => { + it('admits a send that arrives after the restart has already claimed the lease', async () => { await loseOwner() const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 let claimed = () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 181b40660f8f..e2e2fb81102e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -166,8 +166,8 @@ describe('a send into a published session whose child ended before startup', () await send('hello again', releasedFence) - // The client was current as of the lost owner, so the send is rebased onto the fence the - // resume published and admitted once, with no stale round trip. + // A send still fenced to the lost owner is admitted once against the restarted one, with no + // stale round trip. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).toHaveBeenCalledOnce() From c753e8ab9aa203a26db40fa8cf1cbdbd1d067709 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:31:08 -0700 Subject: [PATCH 027/101] fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. --- ...claude-structured-dispatch-test-support.ts | 4 +- src/main/claude/claude-structured-dispatch.ts | 19 +- .../claude/claude-structured-options.test.ts | 4 +- .../claude-structured-prompt-ownership.ts | 18 +- .../claude/claude-structured-real-cli.test.ts | 2 +- .../claude-structured-session-acquisition.ts | 50 +- .../claude-structured-session-adapter.ts | 10 +- ...laude-structured-session-exit-lifecycle.ts | 4 +- .../claude-structured-session-publication.ts | 4 +- ...claude-structured-session-recovery.test.ts | 2 +- .../claude-structured-session-startup-gate.ts | 172 ----- ...claude-structured-session-startup-state.ts | 58 ++ .../claude-structured-session-startup.test.ts | 136 ++-- .../claude-structured-session-startup.ts | 17 +- .../claude/claude-structured-session-state.ts | 4 +- .../claude-structured-session-test-support.ts | 2 +- .../desktop-renderer-runtime-capabilities.ts | 2 + .../journal-pending-submission-recovery.ts | 32 +- .../agent-session-journal/journal-reducer.ts | 6 +- .../journal-restart-reconciliation.ts | 7 +- .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 3 + .../journal-store-contracts.ts | 2 + .../journal-store.test.ts | 28 + .../agent-session-journal/journal-store.ts | 24 +- ...-agent-session-accept-then-deliver.test.ts | 611 ++++++++++++++++++ ...structured-agent-session-adapter-router.ts | 2 + .../structured-agent-session-adapter.ts | 3 + ...structured-agent-session-attach-context.ts | 5 +- ...structured-agent-session-attach-failure.ts | 2 - .../structured-agent-session-attach-flow.ts | 6 +- ...ured-agent-session-attach-orchestration.ts | 73 +-- ...gent-session-attach-reconciliation.test.ts | 32 + .../structured-agent-session-attach.ts | 83 ++- ...ructured-agent-session-close-retry.test.ts | 68 +- ...uctured-agent-session-conversation-open.ts | 153 +++++ ...session-dead-generation-settlement.test.ts | 4 + ...gent-session-dead-generation-settlement.ts | 31 +- .../structured-agent-session-delivery-loop.ts | 152 +++++ ...ctured-agent-session-forget-status.test.ts | 17 +- .../structured-agent-session-host-delivery.ts | 91 +++ .../structured-agent-session-host-lifetime.ts | 22 +- ...structured-agent-session-host-mutations.ts | 62 +- .../structured-agent-session-host.test.ts | 16 +- .../structured-agent-session-host.ts | 43 +- ...ured-agent-session-late-settlement.test.ts | 33 +- ...t-session-launch-send-after-create.test.ts | 18 +- ...gent-session-legacy-handoff-record.test.ts | 53 +- ...ctured-agent-session-mutation-admission.ts | 6 +- ...structured-agent-session-mutation-plans.ts | 16 +- ...agent-session-operation-settlement.test.ts | 40 +- ...ured-agent-session-operation-settlement.ts | 7 +- ...ed-agent-session-option-settlement.test.ts | 5 + ...red-agent-session-provider-started.test.ts | 8 +- ...uctured-agent-session-read-restore.test.ts | 21 +- .../structured-agent-session-read-restore.ts | 108 +--- ...ed-agent-session-readable-restorer.test.ts | 7 +- ...uctured-agent-session-readable-restorer.ts | 33 +- ...ctured-agent-session-refusal-retry.test.ts | 34 +- ...-session-restart-continuation-wait.test.ts | 63 ++ ...ent-session-restart-failure-filing.test.ts | 23 +- ...ssion-restart-interruption-test-harness.ts | 15 +- ...ed-agent-session-restart-ownership.test.ts | 13 +- ...ured-agent-session-restart-restore.test.ts | 56 +- ...tructured-agent-session-restart-restore.ts | 39 +- ...red-agent-session-restart-resume-wiring.ts | 10 +- ...ctured-agent-session-resume-eligibility.ts | 2 +- .../structured-agent-session-reveal.test.ts | 23 +- .../structured-agent-session-reveal.ts | 10 +- .../structured-agent-session-rewind.test.ts | 8 + ...red-agent-session-send-idempotency.test.ts | 9 +- ...red-agent-session-send-preparation.test.ts | 285 ++++---- ...ructured-agent-session-send-preparation.ts | 227 ++----- ...session-send-restarts-failed-start.test.ts | 29 +- ...ured-agent-session-send-settlement.test.ts | 2 +- ...tructured-agent-session-send-settlement.ts | 42 +- .../structured-agent-session-send.test.ts | 127 ++-- ...agent-session-settled-attach-retry.test.ts | 4 +- ...uctured-agent-session-start-failure-row.ts | 48 ++ ...red-agent-session-starting-release.test.ts | 34 +- ...agent-session-startup-failure-exit.test.ts | 3 +- ...red-agent-session-surface-lifetime.test.ts | 45 +- .../structured-agent-session-turns.ts | 111 ++-- ...ured-agent-session-unexpected-exit.test.ts | 11 +- ...tured-agent-session-working-at-teardown.ts | 3 + .../structured-compaction-recovery.ts | 21 +- ...ructured-conversation-command-admission.ts | 9 +- .../agent-session-operation-admission.ts | 4 +- ...e-structured-send-held-for-startup.test.ts | 27 +- ...-send-restart-dies-before-dispatch.test.ts | 143 ++-- .../structured-mailbox-pointer-host.test.ts | 24 +- .../structured-mailbox-pointer-host.ts | 16 +- ...stration-structured-worker-session.test.ts | 22 +- ...orchestration-structured-worker-session.ts | 14 +- ...d-agent-session-send-compatibility.test.ts | 84 +++ ...ctured-agent-session-send-compatibility.ts | 23 +- .../methods/structured-agent-session.test.ts | 41 +- ...d-agent-session-integration-replay.test.ts | 5 +- ...ructured-agent-session-integration.test.ts | 23 +- ...uctured-agent-session-runtime-exit.test.ts | 3 +- .../native-chat/NativeChatDeliveryRetry.tsx | 8 +- ...se-native-chat-structured-composer-send.ts | 5 +- .../use-structured-agent-session-mutate.ts | 3 +- ...nt-session-outbox-rejection-cause.test.tsx | 136 +++- ...e-structured-agent-session-outbox.test.tsx | 10 +- .../use-structured-agent-session-outbox.ts | 11 +- src/shared/agent-session-error-text.test.ts | 23 + src/shared/agent-session-error-text.ts | 22 + src/shared/agent-session-journal-schemas.ts | 4 +- src/shared/agent-session-journal-types.ts | 7 + src/shared/agent-session-mutation-envelope.ts | 6 + src/shared/agent-session-queued-submission.ts | 11 + src/shared/protocol-version.ts | 6 + ...ctured-agent-session-dispatch-rejection.ts | 10 +- src/shared/structured-agent-session-outbox.ts | 23 +- ...ructured-agent-session-send-disposition.ts | 27 +- ...tured-agent-session-unanswered-dispatch.ts | 5 + ...ss-version-agent-session-wire.unit.test.ts | 103 ++- 118 files changed, 3112 insertions(+), 1489 deletions(-) delete mode 100644 src/main/claude/claude-structured-session-startup-gate.ts create mode 100644 src/main/claude/claude-structured-session-startup-state.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts create mode 100644 src/shared/agent-session-error-text.test.ts create mode 100644 src/shared/agent-session-error-text.ts create mode 100644 src/shared/agent-session-queued-submission.ts diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index c9e16084a7da..db84bd432e9a 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -4,7 +4,7 @@ import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import { createClaudeSessionStartup } from './claude-structured-session-startup-state' export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): ClaudeSession { return { @@ -30,7 +30,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartupGate(), state: 'proven' } + startup: { ...createClaudeSessionStartup(), state: 'proven' } } } diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index d93a8ecab0a3..5a5b27965958 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -35,10 +35,8 @@ import { import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { claudeStartupFailureReason, - claudeStartupHoldsWrites, - failClaudeStartupGate, - holdClaudeStartupWrite -} from './claude-structured-session-startup-gate' + failClaudeStartup +} from './claude-structured-session-startup-state' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -226,7 +224,7 @@ export function settleCancelledClaudeDispatchWaiters( * Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { - failClaudeStartupGate(session, new Error('claude stream-json ended before startup completed')) + failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) for (const waiter of session.dispatchWaiters.splice(0)) { retireWaiter(session, waiter) waiter.resolve(null) @@ -236,8 +234,7 @@ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { export async function dispatchClaudeTurn( session: ClaudeSession, input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, - beforeDispatch?: () => Promise, - onSettledLate?: ClaudeLateDispatchSettlement + beforeDispatch?: () => Promise ): Promise { let content: unknown[] try { @@ -276,14 +273,6 @@ export async function dispatchClaudeTurn( parent_tool_use_id: null, session_id: session.providerSessionId } - if (claudeStartupHoldsWrites(session)) { - return holdClaudeStartupWrite(session, { - message, - arm, - ...(beforeDispatch ? { beforeDispatch } : {}), - ...(onSettledLate ? { settleLate: onSettledLate } : {}) - }) - } const pending = { replay: beforeDispatch ? undefined : arm() } const authorize = beforeDispatch ? async () => { diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index ec5e5477f45a..d95936e9c529 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -10,7 +10,7 @@ import { } from './claude-agent-sdk-control-requests' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import { createClaudeSessionStartup } from './claude-structured-session-startup-state' import { claudeStructuredSessionOptionsFrom, observeClaudeFastModeFacts, @@ -46,7 +46,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartupGate(), state: 'proven' } + startup: { ...createClaudeSessionStartup(), state: 'proven' } } } diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index e4c9de982d07..9733cd7b2bf6 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -11,11 +11,6 @@ import { } from './claude-structured-control-actions' import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' -import { - claudeStartupHoldsWrites, - rejectClaudeStartupWrites -} from './claude-structured-session-startup-gate' -import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' /** Conservative user-facing window: below the 30s control deadline, trading * residual slow-pump risk for ensuring delivery bookkeeping cannot block Stop indefinitely. */ @@ -103,14 +98,9 @@ export async function cancelClaudeStructuredTurn(input: { const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration const prompt = request.prompt - // A held prompt was never written, so Stop withdraws it; the drain only writes what it still - // holds. Before startup lands nothing was written, so there is nothing to interrupt either. - let withdrewHeld = false - if (!prompt && claudeStartupHoldsWrites(session) && session.fence === request.fence) { - withdrewHeld = rejectClaudeStartupWrites(session, DISPATCH_REJECTED_CANCELLED) - if (session.startup.state === 'pending') { - return { cancelled: withdrewHeld } - } + // Before startup lands nothing was written, so there is nothing to interrupt. + if (!prompt && session.startup.state === 'pending') { + return { cancelled: false } } if (prompt && session.fence !== request.fence) { return { cancelled: false } @@ -193,7 +183,7 @@ export async function cancelClaudeStructuredTurn(input: { } else if (claim) { session.prompts.releaseClaim(claim) } - return withdrewHeld ? { ...result, cancelled: true } : result + return result } catch (error) { if (claim && !interruptConfirmed) { session.prompts.releaseClaim(claim) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 4181dab43c7a..c117e3e863f1 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -70,7 +70,7 @@ function realAdapter( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.drainStartup(input.identity.sessionId) + await adapter.awaitStarted(input.identity.sessionId) return acquisition } return adapter diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b2efed691ee9..6728f960ef03 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -248,30 +248,34 @@ export async function acquireClaudeSession({ event() } }) - session.startup.settled = settleClaudeSessionStartup({ - session, - facts: readClaudeStartupFacts({ - connection, - initProof, - sessionId, - providerSessionId: launch.providerSessionId, - resumesTranscript: launch.resumesTranscript, - inputOptions: input.options, - requestTimeoutMs: deps.requestTimeoutMs, - emit - }), - isCurrent: () => sessions.get(sessionId) === session, - requestTimeoutMs: deps.requestTimeoutMs, - fault: (error) => callbacks.handleExit(sessionId, attempt, error), - onStarted: (options) => - emit({ - type: 'started', + // Whichever comes first: the start landing or faulting, or the child being ended. + session.startup.settled = Promise.race([ + session.startup.settled, + settleClaudeSessionStartup({ + session, + facts: readClaudeStartupFacts({ + connection, + initProof, sessionId, - fence: input.fence, - acquisitionGeneration: session.acquisitionGeneration, - ...options - }) - }) + providerSessionId: launch.providerSessionId, + resumesTranscript: launch.resumesTranscript, + inputOptions: input.options, + requestTimeoutMs: deps.requestTimeoutMs, + emit + }), + isCurrent: () => sessions.get(sessionId) === session, + requestTimeoutMs: deps.requestTimeoutMs, + fault: (error) => callbacks.handleExit(sessionId, attempt, error), + onStarted: (options) => + emit({ + type: 'started', + sessionId, + fence: input.fence, + acquisitionGeneration: session.acquisitionGeneration, + ...options + }) + }) + ]) // A child whose exit already reached `handleExit` is not handed over as live: the create // fails with the CLI's own diagnostic, as one that died before publish does. if (sessions.get(sessionId) !== session) { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index ded925bf6319..0b8acadf303f 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -12,7 +12,7 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { claudeStartupSettledWithin } from './claude-structured-session-startup-gate' +import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, @@ -114,8 +114,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) - /** Resolves once a published session's startup has landed or faulted it. */ - drainStartup = (sessionId: string): Promise => + /** Resolves once a published session's startup has landed, faulted, or been ended by a close. */ + awaitStarted = (sessionId: string): Promise => this.sessions.get(sessionId)?.startup.settled ?? Promise.resolve() /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ @@ -165,9 +165,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch, (settlement) => - this.deps.onDispatchSettledLate?.({ sessionId: input.sessionId, ...settlement }) - ) + dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) compact: NonNullable = (input) => compactClaudeSession(this.session(input.sessionId), this.compactions, input) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index fb1ab1943b4b..ee41dfc628d1 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -3,7 +3,7 @@ import { claudeRootExitObserved, settleClaudeExitedSession } from './claude-structured-session-close' -import { failClaudeStartupGate } from './claude-structured-session-startup-gate' +import { failClaudeStartup } from './claude-structured-session-startup-state' import type { ClaudeAcquisitionAttempt, ClaudeSession, @@ -32,7 +32,7 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) - failClaudeStartupGate(session, error) + failClaudeStartup(session, error) // Re-enter the provider's close ladder before publishing lifecycle recovery. // An exit callback is root evidence only; the retained tree proof must run // before the host releases and reacquires this exact child. diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 1d4228ed837e..6643bf04c942 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -5,7 +5,7 @@ import type { ClaudeJournalTranslator } from './claude-structured-journal-transl import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import { createClaudeSessionStartup } from './claude-structured-session-startup-state' /** The session as published at spawn: nothing the CLI reports at init is assumed yet. */ export function createClaudeSessionPublication(input: { @@ -65,7 +65,7 @@ export function createClaudeSessionPublication(input: { translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), - startup: createClaudeSessionStartupGate() + startup: createClaudeSessionStartup() } } } diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index ef8e0c654695..8ddc29a2e676 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -338,7 +338,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { spawnToken: 'spawn-9', events: journalSink }) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') const first = claude.connections[0] const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') const oldSession = ( diff --git a/src/main/claude/claude-structured-session-startup-gate.ts b/src/main/claude/claude-structured-session-startup-gate.ts deleted file mode 100644 index 0df789361915..000000000000 --- a/src/main/claude/claude-structured-session-startup-gate.ts +++ /dev/null @@ -1,172 +0,0 @@ -// A Claude session is published once its child is spawned, before the CLI has answered -// initialize. Prompts sent in that window are held here and written, in order, once startup -// lands (init facts read and saved options restored), so a first turn never runs under -// defaults the restore was about to replace. A held prompt was never written, so a startup -// that fails rejects it rather than leaving its delivery in doubt. - -import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' -import { dispatchWriteFailureReason } from '../../shared/structured-agent-session-dispatch-rejection' -import { providerStartupFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' -import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' -import { - forgetRetiredWaiter, - forgetWaiter, - retireWaiter -} from './claude-structured-dispatch-waiters' -import type { - ClaudeDispatchWaiter, - ClaudeLateDispatchOutcome, - ClaudeSession -} from './claude-structured-session-state' - -type ClaudeStartupHeldWrite = { - waiter: ClaudeDispatchWaiter - message: Record - settleLate?: (outcome: ClaudeLateDispatchOutcome) => void -} - -export type ClaudeSessionStartupGate = { - state: 'pending' | 'proven' | 'failed' - held: ClaudeStartupHeldWrite[] - /** Held prompts are still being written; later prompts must queue behind them. */ - draining: boolean - failure: Error | null - /** Resolves once startup has landed or faulted the session; never rejects. */ - settled: Promise -} - -export function createClaudeSessionStartupGate(): ClaudeSessionStartupGate { - return { state: 'pending', held: [], draining: false, failure: null, settled: Promise.resolve() } -} - -export function claudeStartupFailureReason(session: ClaudeSession): string | null { - return session.startup.state === 'failed' - ? providerStartupFailureRejection(session.startup.failure ?? undefined) - : null -} - -/** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ -export function claudeStartupSettledWithin( - session: ClaudeSession | undefined, - timeoutMs: number -): Promise { - if (session?.startup.state !== 'pending') { - return Promise.resolve() - } - let timer: ReturnType | undefined - return Promise.race([ - session.startup.settled, - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - }) - ]).finally(() => clearTimeout(timer)) -} - -export function claudeStartupHoldsWrites(session: ClaudeSession): boolean { - return session.startup.state === 'pending' || session.startup.draining -} - -/** Admits a prompt while startup is pending; it is written when `openClaudeStartupGate` runs. */ -export async function holdClaudeStartupWrite( - session: ClaudeSession, - input: { - message: Record - arm: () => { waiter: ClaudeDispatchWaiter } - beforeDispatch?: () => Promise - settleLate?: (outcome: ClaudeLateDispatchOutcome) => void - } -): Promise { - if (input.beforeDispatch) { - try { - await input.beforeDispatch() - } catch (error) { - if (error instanceof AgentSessionPreDispatchError) { - throw error - } - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } - } - } - // Startup may have failed while the admission barrier ran. - const failed = claudeStartupFailureReason(session) - if (failed) { - return { state: 'rejected', reason: failed } - } - const { waiter } = input.arm() - session.startup.held.push({ - waiter, - message: input.message, - ...(input.settleLate ? { settleLate: input.settleLate } : {}) - }) - // Startup finished writing what it held while the barrier ran; nothing else would drain this. - if (!claudeStartupHoldsWrites(session)) { - void drainClaudeStartupWrites(session) - } - return { state: 'admitted' } -} - -export async function openClaudeStartupGate(session: ClaudeSession): Promise { - const gate = session.startup - if (gate.state !== 'pending') { - return - } - gate.state = 'proven' - await drainClaudeStartupWrites(session) -} - -async function drainClaudeStartupWrites(session: ClaudeSession): Promise { - const gate = session.startup - gate.draining = true - try { - for (let held = gate.held.shift(); held; held = gate.held.shift()) { - await writeHeld(session, held) - } - } finally { - gate.draining = false - } -} - -async function writeHeld(session: ClaudeSession, held: ClaudeStartupHeldWrite): Promise { - try { - await session.connection.send(held.message) - } catch (error) { - if (held.waiter.settledUuid) { - return - } - if (claudeUserMessageWasProvablyUnwritten(error)) { - rejectHeld(session, held, dispatchWriteFailureReason(error)) - return - } - // Possibly written: only a replay or the child's exit can settle it now. - retireWaiter(session, held.waiter) - held.waiter.resolve(null) - } -} - -function rejectHeld(session: ClaudeSession, held: ClaudeStartupHeldWrite, reason: string): void { - forgetWaiter(session, held.waiter) - forgetRetiredWaiter(session, held.waiter) - held.waiter.resolve(null) - if (held.waiter.clientMessageId) { - held.settleLate?.({ clientMessageId: held.waiter.clientMessageId, state: 'rejected', reason }) - } -} - -/** Rejects every held prompt with `reason`; true when any was held. */ -export function rejectClaudeStartupWrites(session: ClaudeSession, reason: string): boolean { - const held = session.startup.held.splice(0) - for (const entry of held) { - rejectHeld(session, entry, reason) - } - return held.length > 0 -} - -/** Startup cannot land any more; nothing held was written, so all of it is rejected. */ -export function failClaudeStartupGate(session: ClaudeSession, error: Error): void { - const gate = session.startup - if (gate.state === 'pending') { - gate.state = 'failed' - gate.failure = error - } - rejectClaudeStartupWrites(session, providerStartupFailureRejection(error)) -} diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts new file mode 100644 index 000000000000..1bda560377ad --- /dev/null +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -0,0 +1,58 @@ +// Where a Claude start stands. A session is published once its child is spawned, before the CLI +// has answered initialize. Nothing is written to it until startup lands (init facts read and saved +// options restored): the host's delivery loop waits on `settled` before it hands a message over, +// so a first turn never runs under defaults the restore was about to replace. + +import { providerStartupFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' +import type { ClaudeSession } from './claude-structured-session-state' + +export type ClaudeSessionStartup = { + state: 'pending' | 'proven' | 'failed' + failure: Error | null + /** Resolves once startup has landed or faulted, or the child exited or was closed; never + * rejects. A close must end it: the delivery loop waits here, and a start Stop cut short + * would otherwise hold that loop forever. */ + settled: Promise + end: () => void +} + +export function createClaudeSessionStartup(): ClaudeSessionStartup { + let end: () => void = () => undefined + const ended = new Promise((resolve) => { + end = resolve + }) + return { state: 'pending', failure: null, settled: ended, end } +} + +export function claudeStartupFailureReason(session: ClaudeSession): string | null { + return session.startup.state === 'failed' + ? providerStartupFailureRejection(session.startup.failure ?? undefined) + : null +} + +/** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ +export function claudeStartupSettledWithin( + session: ClaudeSession | undefined, + timeoutMs: number +): Promise { + if (session?.startup.state !== 'pending') { + return Promise.resolve() + } + let timer: ReturnType | undefined + return Promise.race([ + session.startup.settled, + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + ]).finally(() => clearTimeout(timer)) +} + +/** Startup cannot land any more: the child exited, was closed, or its start faulted. */ +export function failClaudeStartup(session: ClaudeSession, error: Error): void { + const startup = session.startup + if (startup.state === 'pending') { + startup.state = 'failed' + startup.failure = error + } + startup.end() +} diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a106c172c45c..e81d204f9ca1 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -55,7 +55,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(adapter.readCommands('session-1')).toBeUndefined() await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') expect(events.find((event) => event.type === 'options')).toMatchObject({ models: [{ value: 'claude-sonnet' }] @@ -65,7 +65,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('reports `started` once saved options are restored, before any held prompt is written', async () => { + it('reports `started` once saved options are restored, having written no prompt of its own', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, initModel: 'claude-opus-9' }) const { adapter, events } = startingAdapter(claude) const order: string[] = [] @@ -74,11 +74,10 @@ describe('Claude structured session publishes before the CLI answers initialize' return undefined } await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) - await adapter.dispatch(PROMPT) expect(events.some((event) => event.type === 'started')).toBe(false) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') const startedAt = events.findIndex((event) => event.type === 'started') expect(events[startedAt]).toEqual({ @@ -90,9 +89,9 @@ describe('Claude structured session publishes before the CLI answers initialize' reportedOptions: expect.objectContaining({ model: 'opus' }), restoreSkippedOptions: [] }) - // The restore wrote the saved model before `started`, and the held prompt only after it. + // The restore wrote the saved model before `started`; no message waits inside the adapter. expect(order).toEqual(['set_model']) - expect(claude.connections[0].sent).toHaveLength(1) + expect(claude.connections[0].sent).toEqual([]) expect(events.slice(0, startedAt).some((event) => event.type === 'options')).toBe(true) await adapter.closeAll() }) @@ -133,52 +132,37 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('holds a prompt sent before init and writes it once startup lands', async () => { + // The host's delivery loop waits here before it hands a message over, so the adapter no longer + // holds prompts of its own: nothing is written until startup lands because nothing is sent. + it('resolves awaitStarted only once startup lands', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent).toEqual([]) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') - - expect(claude.connections[0].sent).toHaveLength(1) - expect(claude.connections[0].sent[0]).toMatchObject({ type: 'user' }) - await adapter.closeAll() - }) - - it('writes a prompt whose admission barrier was still running when startup landed', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let passBarrier = (): void => {} - const barrier = new Promise((resolve) => { - passBarrier = resolve + let started = false + const waited = adapter.awaitStarted('session-1').then(() => { + started = true }) - const dispatched = adapter.dispatch({ ...PROMPT, beforeDispatch: () => barrier }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') - passBarrier() + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) + expect(started).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await waited - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent.filter((message) => message.type === 'user')).toHaveLength(1) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) await adapter.closeAll() }) - it('ends the session with the exit reason when the CLI dies before init, and rejects held prompts', async () => { + it('ends the session with the exit reason when the CLI dies before init', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, exitBeforeInit: 'claude stream-json exited (code 1): stderr says no' }) - const { adapter, events, late } = startingAdapter(claude) + const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -186,9 +170,6 @@ describe('Claude structured session publishes before the CLI answers initialize' cause: 'unexpected-exit', startupUnproven: true }) - expect(late).toEqual([ - expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) - ]) expect(claude.connections[0].sent).toEqual([]) expect(claude.connections[0].closeCount).toBe(1) }) @@ -203,7 +184,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') await adapter.drainObservedExits() // A failed start is released on the same evidence a failed create is; a proven-live @@ -218,7 +199,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -227,82 +208,41 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) - it('closes a session stopped before init without faulting it or writing held prompts', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, events, late } = startingAdapter(claude) + // A Stop that closes a child still starting must end the wait the host's delivery loop is in, + // though initialize never answers; otherwise every later send joins a loop that never moves. + it('ends the wait on a start closed before init, without faulting it', async () => { + const claude = fakeClaude({ initDelayMs: 10 * SLOW_INIT_MS }) + const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) + let ended = false + const waited = adapter.awaitStarted('session-1').then(() => { + ended = true + }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await vi.advanceTimersByTimeAsync(0) + await waited + expect(ended).toBe(true) const connection = claude.connections[0] expect(connection.closeCount).toBe(1) expect(connection.sent).toEqual([]) expect(connection.calls.map(({ subtype }) => subtype)).not.toContain('get_settings') - expect(late).toEqual([ - expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) - ]) expect(events.some((event) => event.type === 'ended' && event.startupUnproven)).toBe(false) expect(events.some((event) => event.type === 'started')).toBe(false) }) - it('withdraws a held prompt when the turn is cancelled before init', async () => { + it('interrupts nothing when Stop lands before init: nothing was written', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, late } = startingAdapter(claude) + const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') expect(claude.connections[0].sent).toEqual([]) - expect(late).toEqual([ - expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) - ]) - await adapter.closeAll() - }) - - it('withdraws the prompts still held when Stop lands while startup is writing them', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, late } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - const connection = claude.connections[0] - const send = connection.send - let landFirstWrite = (): void => {} - const firstWrite = new Promise((resolve) => { - landFirstWrite = resolve - }) - let writes = 0 - connection.send = async (message, beforeDispatch) => { - writes += 1 - if (writes === 1) { - await firstWrite - } - return send(message, beforeDispatch) - } - await adapter.dispatch(PROMPT) - await adapter.dispatch({ ...PROMPT, clientMessageId: 'client-2' }) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - // Startup has landed and is writing the first held prompt. - expect(writes).toBe(1) - const cancelled = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - await vi.advanceTimersByTimeAsync(0) - landFirstWrite() - await vi.advanceTimersByTimeAsync(5_000) - await adapter.drainStartup('session-1') - - expect(connection.sent.filter((message) => message.type === 'user')).toHaveLength(1) - expect(late).toContainEqual( - expect.objectContaining({ clientMessageId: 'client-2', state: 'rejected' }) - ) - // Stop withdrew something, so it answers as a cancel whatever the interrupt made of the turn. - await expect(cancelled).resolves.toEqual({ cancelled: true }) await adapter.closeAll() }) }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index aa1f9a02eccd..d486a670a13e 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -26,10 +26,7 @@ import { observeClaudeSettingsApplied, readClaudeSettingsEffort } from './claude-structured-session-options' -import { - failClaudeStartupGate, - openClaudeStartupGate -} from './claude-structured-session-startup-gate' +import { failClaudeStartup } from './claude-structured-session-startup-state' import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' export type ClaudeInitProof = { @@ -163,8 +160,8 @@ function claudeStartedReportedOptions( return persisted } -/** Applies startup facts to the published session, restores saved options, then releases - * held prompts. Any failure faults the session so the user sees why it never started. */ +/** Applies startup facts to the published session and restores saved options; only then does the + * session take input. Any failure faults the session so the user sees why it never started. */ export async function settleClaudeSessionStartup(input: { session: ClaudeSession facts: Promise @@ -179,7 +176,7 @@ export async function settleClaudeSessionStartup(input: { if (input.isCurrent()) { return false } - failClaudeStartupGate(session, new Error('claude session closed before startup completed')) + failClaudeStartup(session, new Error('claude session closed before startup completed')) return true } try { @@ -198,13 +195,15 @@ export async function settleClaudeSessionStartup(input: { ), restoreSkippedOptions: [...session.restoreSkippedOptions] }) - await openClaudeStartupGate(session) + if (session.startup.state === 'pending') { + session.startup.state = 'proven' + } } } catch (caught) { const error = caught instanceof Error ? caught : new Error(String(caught)) // A close or exit that already ended startup owns how the session ends. const endedElsewhere = session.startup.state !== 'pending' - failClaudeStartupGate(session, error) + failClaudeStartup(session, error) if (!endedElsewhere && input.isCurrent()) { input.fault(error) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index fc7e8f8601ea..883e52853095 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -23,7 +23,7 @@ import type { } from '../../shared/agent-session-wire' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import type { ClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import type { ClaudeSessionStartup } from './claude-structured-session-startup-state' export type ClaudeAuthDiagnostic = { apiKeySourceConfigured: boolean @@ -190,7 +190,7 @@ export type ClaudeSession = { events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void /** Published at spawn; init facts, option restore and queued prompts land when startup does. */ - startup: ClaudeSessionStartupGate + startup: ClaudeSessionStartup } export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 023e0ba46b70..c8e3d86b0e86 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -220,7 +220,7 @@ export function adapterFor( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.drainStartup(input.identity.sessionId) + await adapter.awaitStarted(input.identity.sessionId) return acquisition } return adapter diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 63ba9b1d0b11..0bcfd1bd686d 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -1,5 +1,6 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, @@ -25,6 +26,7 @@ import { export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c4138ba22418..10f6821fcc53 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -1,8 +1,11 @@ +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from './journal-store' /** Settles every submission a process fact left unanswerable. Doubt is never - * proof of non-delivery, so nothing here ever becomes re-deliverable. */ + * proof of non-delivery, so nothing here ever becomes re-deliverable. A queued + * submission was never handed over, so it is not in doubt and is left alone. */ export async function markJournalPendingSubmissionsUnknown( journal: AgentSessionJournal, fence: number, @@ -12,8 +15,9 @@ export async function markJournalPendingSubmissionsUnknown( .submissions() .filter( (entry) => - entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true) + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) ) for (const entry of unresolved) { // An earlier reason already names a sharper fact than "the host restarted". @@ -55,3 +59,25 @@ export async function rejectJournalPendingSubmissions( } return unwritten.map((entry) => entry.clientMessageId) } + +/** Rejects queued submissions — accepted, never handed over, so provably unwritten. */ +export async function rejectJournalQueuedSubmissions( + journal: AgentSessionJournal, + fence: number, + reason: string, + which: (submission: AgentJournalSubmission) => boolean = () => true +): Promise { + const queued = journal + .submissions() + .filter((entry) => isQueuedAgentJournalSubmission(entry) && which(entry)) + for (const entry of queued) { + await journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'rejected', + reason, + fence, + recovered: true + }) + } + return queued.map((entry) => entry.clientMessageId) +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 273502c8cf2b..3fbee85a417e 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -254,7 +254,8 @@ function applySubmission( providerItemId: null, reason: null, submittedAt: row.ts, - resolvedAt: null + resolvedAt: null, + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row)) @@ -277,6 +278,9 @@ function applyDispatch( submission.providerItemId = row.providerItemId submission.reason = row.reason submission.resolvedAt = row.state === 'pending' ? null : row.ts + if (row.state === 'pending') { + submission.handedOverAt = row.ts + } if (row.recovered) { submission.recovered = row.recovered } else { diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 626baa9820aa..f37571814bbf 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -18,6 +18,7 @@ import { agentJournalItemKey, agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from './journal-store' import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler' @@ -42,7 +43,11 @@ function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmis const { items, submissions } = journal.snapshot() const bodies = new Map(items.map((item) => [item.itemId, item.body])) return submissions.filter((submission) => { - if (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') { + if ( + (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') || + // Never handed over, so provider history cannot hold it. + isQueuedAgentJournalSubmission(submission) + ) { return false } const body = bodies.get(agentJournalSubmissionKey(submission.clientMessageId)) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index e17ccf0cdf87..e56ca86a7fb5 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -65,6 +65,7 @@ export function journalSubmissionRowBuilder( payloadFingerprint: string body: AgentJournalMessageItem fence: number + handoverRecorded?: true } ): RowBuilder { return (seq, ts) => @@ -267,6 +268,7 @@ export function buildJournalSubmissionRow(input: { seq: number fence: number ts: number + handoverRecorded?: true }): JournalSubmissionRow { return { kind: 'submission', @@ -274,7 +276,8 @@ export function buildJournalSubmissionRow(input: { payloadFingerprint: input.payloadFingerprint, providerHandle: input.providerHandle, body: input.body, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), + ...(input.handoverRecorded ? { handoverRecorded: true } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 99cb47971e95..0f29f8b38794 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -77,6 +77,9 @@ export type JournalSubmissionRow = JournalRowBase & { payloadFingerprint: string providerHandle: AgentSessionProviderHandle body: AgentJournalMessageItem + /** Accepted to be handed over by a later `dispatch{pending}` row; absent on rows whose writer + * dispatched in the same step. Older readers keep the key and ignore it. */ + handoverRecorded?: true } export type JournalDispatchRow = JournalRowBase & { diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index cea47e381fe4..79d86f80dbbf 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -59,6 +59,8 @@ export type JournalSubmissionInput = { payloadFingerprint: string body: AgentJournalMessageItem fence: number + /** The send is accepted now and handed over later, by a `dispatch{pending}` row. */ + handoverRecorded?: true } export type JournalItemAppendInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index ffaea5c5d6f1..3637f03dab6c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -424,3 +424,31 @@ async function withJournalDatabase( opened.db.close() } } + +describe('what a handle found on disk when it opened', () => { + const submission = (clientMessageId: string) => ({ + clientMessageId, + payloadFingerprint: 'fp', + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + fence: 1, + handoverRecorded: true as const + }) + + it('names rows an earlier handle wrote, and never a row of a later epoch', async () => { + const earlier = await open() + await earlier.appendItem(item(1), body('one'), { fence: 1 }) + await earlier.appendSubmission(submission('earlier')) + await earlier.close() + + const journal = await open() + const leftover = journal.submissions().find((entry) => entry.clientMessageId === 'earlier') + expect(journal.wroteBeforeOpen(leftover?.acceptedSequence)).toBe(true) + + // Sequences restart with an epoch, so a row accepted after it can sit below the open cursor. + await journal.replaceEpochItems('handle_forked', 1, []) + await journal.appendSubmission(submission('later')) + const later = journal.submissions().find((entry) => entry.clientMessageId === 'later') + expect(later?.acceptedSequence).toBeLessThanOrEqual(2) + expect(journal.wroteBeforeOpen(later?.acceptedSequence)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 568fbe4d39d3..5119f7ef597c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -28,7 +28,8 @@ import { readJournalRowsAfterCursor, type JournalLoad } from './journal-open' import { journalDatabaseFile } from './journal-paths' import { markJournalPendingSubmissionsUnknown, - rejectJournalPendingSubmissions + rejectJournalPendingSubmissions, + rejectJournalQueuedSubmissions } from './journal-pending-submission-recovery' import { applyJournalRow, @@ -75,6 +76,7 @@ export class AgentSessionJournal { private state: JournalReducerState private readOnly = false private malformedRows = 0 + private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } private database: OpenJournalDatabase | null = null private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue @@ -147,6 +149,16 @@ export class AgentSessionJournal { return this.journalDir } + /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle + * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ + wroteBeforeOpen(sequence: number | undefined): boolean { + return ( + sequence !== undefined && + this.state.epoch === this.openedThrough.epoch && + sequence <= this.openedThrough.sequence + ) + } + /** What the last open's repair did. */ get repair(): { malformedRows: number } { return { malformedRows: this.malformedRows } @@ -157,6 +169,7 @@ export class AgentSessionJournal { this.database = openJournalDatabase(this.dbPath) try { await this.restore() + this.openedThrough = this.cursor() } catch (error) { // Nothing else holds a reference to this connection, so a throw here is // the leak site unless the store releases it itself — and a close that @@ -310,6 +323,15 @@ export class AgentSessionJournal { return rejectJournalPendingSubmissions(this, fence, reason) } + /** Reject sends accepted but never handed over, optionally only those `which` names. */ + async rejectQueuedSubmissions( + fence: number, + reason: string, + which?: (submission: AgentJournalSubmission) => boolean + ): Promise { + return rejectJournalQueuedSubmissions(this, fence, reason, which) + } + /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, * and an unreadable schema. It invalidates every cursor; clients reload. */ async rollEpoch(reason: AgentJournalEpochReason, fence: number): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts new file mode 100644 index 000000000000..efba24208d15 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -0,0 +1,611 @@ +// A send is accepted, then delivered: the host answers once the message is recorded, and the +// session's delivery loop starts a provider child for it and hands it over. Against the real host, +// store and journal; each assertion reads what an open chat or the journal's next reader sees. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { journalIdentityFor } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { persistRewindRecord } from './structured-rewind-recovery' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let dispatch: Mock +let adapterExtras: Partial +let releaseGraceMs: number + +const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } +}) + +async function startHost(): Promise { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined), + ...adapterExtras + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + releaseGraceMs, + now: () => NOW + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-accept-deliver-')) + resetHostTestOperationIds() + adapterExtras = {} + releaseGraceMs = 60_000 + acquire = vi.fn(spawnChild) + dispatch = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${dispatch.mock.calls.length}`, + ordinal: dispatch.mock.calls.length + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await startHost() + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } +} + +/** Accepted at once, whatever the child is doing; answers the message id. */ +async function accept(text: string): Promise { + const params = sendParams(text) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + return params.envelope.clientOperationId +} + +function stop() { + const turnId = 'turn-none' + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: { turnId } + }) + }, + turnId + }) +} + +function submission(id: string): AgentJournalSubmission | undefined { + return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) +} + +/** Read back after the conversation was closed, through the same open any reader takes. */ +async function reopened(id: string): Promise { + await host.revealSession(SESSION) + return submission(id) +} + +function errorRows(): string[] { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) +} + +let subscriptions = 0 + +function subscribe(): AgentSessionSubscribeEvent[] { + const events: AgentSessionSubscribeEvent[] = [] + subscriptions += 1 + // Cloned as received: a frame shares the journal's live objects, which later rows revise. + host.subscribe({ + id: `sub-${subscriptions}`, + sessionId: SESSION, + emit: (event) => events.push(structuredClone(event)) + }) + return events +} + +/** What an open chat was told about one message, in frame order. */ +function framedStates(events: AgentSessionSubscribeEvent[], id: string): string[] { + return events.flatMap((event) => + event.type === 'batch' + ? event.batch.submissions + .filter((entry) => entry.clientMessageId === id) + .map((entry) => + entry.dispatchState === 'pending' && entry.handedOverAt !== undefined + ? 'handed-over' + : entry.dispatchState + ) + : [] + ) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((next, fail) => { + resolve = next + reject = fail + }) + return { promise, resolve, reject } +} + +/** Rows written by an earlier host process that ended before handing them over. */ +async function writeAsEarlierProcess( + write: (journal: AgentSessionJournal, fence: number) => Promise +): Promise { + await host.close(SESSION) + const record = store.getRecord(SESSION)! + const params = attachParamsForRecord(record, { + clientOperationId: 'earlier', + expectedRuntimeFence: record.lease.runtimeFence + }) + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor(record, params), + journalDir: journalDirectoryFor(root, { + workspaceId: record.location.workspaceId, + sessionId: SESSION + }) + }) + await write(journal, record.lease.runtimeFence) + await journal.close() +} + +function earlierSubmission(id: string, text: string, handoverRecorded?: true) { + const body = hostTestMessage(text) + return { + clientMessageId: id, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }), + body, + ...(handoverRecorded ? { handoverRecorded } : {}) + } +} + +describe('a send is answered at acceptance', () => { + it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { + await host.close(SESSION) + const starting = deferred() + acquire.mockImplementationOnce(async (input) => { + await starting.promise + return spawnChild(input) + }) + + const answering = deferred() + const answer = dispatch.getMockImplementation()! + dispatch.mockImplementationOnce(async (input) => { + await answering.promise + return answer(input) + }) + + const id = await accept('hello') + const events = subscribe() + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + expect(dispatch).not.toHaveBeenCalled() + + starting.resolve() + await eventually(() => expect(framedStates(events, id)).toEqual(['handed-over'])) + answering.resolve() + await eventually(() => expect(framedStates(events, id)).toEqual(['handed-over', 'accepted'])) + }) + + it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { + await host.close(SESSION) + const starting = deferred() + acquire.mockImplementationOnce(async (input) => { + await starting.promise + return spawnChild(input) + }) + const first = await accept('first') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + const second = host.send(CALLER, sendParams('second')) + + starting.resolve() + const secondResult = await second + if (!secondResult.ok) { + throw new Error('the second send was refused') + } + const secondId = secondResult.value.clientMessageId + await eventually(() => expect(submission(secondId)?.dispatchState).toBe('accepted')) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) + // The second was accepted while the start held the queue — before the first was handed over. + expect(submission(secondId)!.submittedAt).toBeLessThanOrEqual(submission(first)!.handedOverAt!) + const rows = host.journalSnapshot(SESSION).submissions + expect(rows.map((row) => row.clientMessageId)).toEqual([first, secondId]) + }) +}) + +describe('a start the chat needed and did not get', () => { + it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const first = await accept('first') + const second = await accept('second') + + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + const rows = errorRows() + expect(rows).toHaveLength(1) + expect(rows[0]).toContain('spawn codex ENOENT') + expect(submission(first)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) + expect(submission(second)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) + + const next = await accept('after the fix') + await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + expect(errorRows()).toHaveLength(1) + }) + + it.each([ + [ + 'eligibility', + () => { + adapterExtras = { supportsLocation: () => false } + }, + 'cannot resume' + ], + [ + 'spawn', + () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), + 'spawn codex ENOENT' + ], + [ + 'auth', + () => + acquire.mockRejectedValueOnce( + new AgentSessionPreSpawnError(new Error('Not logged in. Please run /login.')) + ), + 'Not logged in' + ] + ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, cause) => { + await host.close(SESSION) + arrange() + await host.flushAllStreamedEvents() + await startHost() + const id = await accept('hello') + const events = subscribe() + + await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + expect(errorRows()).toHaveLength(1) + expect(errorRows()[0]).toContain(cause) + const framedRows = events.flatMap((event) => + event.type === 'batch' || event.type === 'snapshot' + ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + : [] + ) + expect(framedRows.length).toBeGreaterThan(0) + }) + + it('names the start failure on queued messages when the attach fails after acquiring (W4′a)', async () => { + await host.close(SESSION) + const id = await accept('hello') + // The attach's own success record is the post-acquisition step that fails. + const record = vi.spyOn(store, 'recordOperationOutcome') + record.mockImplementation(async (input) => { + if (input.operationId !== id && input.outcome.status === 'succeeded') { + record.mockRestore() + throw new Error('record store write failed') + } + return AgentSessionRecordStore.prototype.recordOperationOutcome.call(store, input) + }) + + // Read through the open any reader takes, so a conversation the failure dropped is reopened + // and its message read as that reopen settles it. + let settled: AgentJournalSubmission | undefined + await eventually(async () => { + settled = await reopened(id) + expect(settled?.dispatchState).not.toBe('pending') + }) + // The message names the start that failed, not a close or a restart it never met. + expect(settled).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringContaining('record store write failed') + }) + expect(errorRows()).toEqual([settled?.reason]) + }) +}) + +describe('what an earlier host process left behind', () => { + it('rejects a message it accepted and never handed over, as not sent (W4′b)', async () => { + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('queued', 'q', true), fence }) + }) + + await host.revealSession(SESSION) + + expect(submission('queued')).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_HOST_RESTARTED + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('leaves a legacy pending message and a handed-over one in doubt, never re-sent (W4′c)', async () => { + const providerHistoryWindow = vi.fn(async () => null) + adapterExtras = { providerHistoryWindow } + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) + await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) + await journal.resolveDispatch({ clientMessageId: 'handed', state: 'pending', fence }) + }) + await host.flushAllStreamedEvents() + await startHost() + + await host.revealSession(SESSION) + + expect(submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + // Deciding them from provider history waits for a won lease (W4′d). + expect(providerHistoryWindow).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +describe('Stop withdraws what is queued', () => { + it('withdraws a crash leftover the open could not settle, ahead of any delivery step (W17a)', async () => { + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('leftover', 'l', true), fence }) + }) + const settle = vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + settle.mockRejectedValueOnce(new Error('journal busy')) + + expect(await stop()).toMatchObject({ ok: true }) + settle.mockRestore() + + expect(submission('leftover')).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(acquire).toHaveBeenCalledTimes(1) + }) + + it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { + await host.close(SESSION) + const starting = deferred() + acquire.mockImplementationOnce(async (input) => { + await starting.promise + return spawnChild(input) + }) + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + const stopped = stop() + + starting.resolve() + expect(await stopped).toMatchObject({ ok: true }) + await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + expect(submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + expect(submission(id)?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + }) + + it('stops a child still proving its start, and the delivery loop ends with it (W17c)', async () => { + const ended = deferred() + const awaitStarted = vi.fn(() => ended.promise) + const closeSession = vi.fn(async () => { + ended.resolve() + return true + }) + adapterExtras = { awaitStarted, closeSession } + await host.close(SESSION) + await startHost() + acquire.mockImplementationOnce(async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const + })) + const id = await accept('hello') + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(await reopened(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(closeSession).toHaveBeenCalled() + // A loop still waiting on that start would swallow this send; it starts a new child instead. + awaitStarted.mockImplementation(async () => undefined) + const next = await accept('after stop') + await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('an eviction between acceptance and handover', () => { + it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { + const started = deferred() + adapterExtras = { awaitStarted: () => started.promise } + await host.close(SESSION) + await startHost() + await host.hold(SESSION, 'surface-1') + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + // Between the delivery loop's start step and its handover step. + await host.close(SESSION) + started.resolve() + + expect(await reopened(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_PROVIDER_CLOSED + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('rejects a queued message behind a handed-over one, which alone stays in doubt (W24)', async () => { + const second = deferred() + const awaitStarted = vi.fn(async (): Promise => undefined) + adapterExtras = { awaitStarted } + await host.close(SESSION) + await startHost() + await host.hold(SESSION, 'surface-1') + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const handed = await accept('handed over') + await eventually(() => expect(submission(handed)?.handedOverAt).toBeDefined()) + awaitStarted.mockImplementation(() => second.promise) + const queued = await accept('still queued') + await eventually(() => expect(awaitStarted).toHaveBeenCalledTimes(2)) + + await host.close(SESSION) + second.resolve() + + expect(await reopened(queued)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_PROVIDER_CLOSED + }) + expect(submission(handed)).toMatchObject({ dispatchState: 'unknown' }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('does not evict an idle child while a message is still queued for it (W24)', async () => { + releaseGraceMs = 0 + const started = deferred() + adapterExtras = { awaitStarted: () => started.promise } + await host.close(SESSION) + await startHost() + await host.hold(SESSION, 'surface-1') + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + host.release(SESSION, 'surface-1') + await new Promise((resolve) => setTimeout(resolve, 20)) + + started.resolve() + // Handed to the child it was queued for; the eviction may follow once nothing is owed. + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + expect(dispatch.mock.calls[0]?.[0].clientMessageId).toBe(id) + expect(acquire).toHaveBeenCalledTimes(2) + }) +}) + +describe('a compaction or rewind an earlier child left prepared', () => { + async function leftPrepared(prepare: (fence: number) => Promise): Promise { + await host.close(SESSION) + await prepare(store.getRecord(SESSION)!.lease.runtimeFence) + // A new process: nothing is open and no view attaches. + await host.flushAllStreamedEvents() + await startHost() + } + + it('settles an interrupted compaction at open, so a send is accepted and delivered (R16)', async () => { + await leftPrepared((fence) => + store.setConversationCommand(SESSION, fence, { + command: 'compact', + runtimeFence: fence, + operationId: 'compact-op', + callerKey: 'client-1', + phase: 'prepared', + state: 'unknown' + }) + ) + + const id = await accept('after the compaction') + + await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ + phase: 'committed', + state: 'unknown' + }) + }) + + it('completes a rewind the provider already applied at open, so a send is accepted (R16)', async () => { + await leftPrepared((fence) => + persistRewindRecord(store, SESSION, fence, { + operationId: 'rewind-op', + callerKey: 'client-1', + itemId: 'orca:rewound', + providerItemId: `codex:${THREAD}:turn-1:0`, + expectedEpoch: 'epoch-before', + phase: 'provider-succeeded', + hydrationVerified: true, + retained: [] + }) + ) + + const id = await accept('after the rewind') + + await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'completed' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 7fa1446d6c53..4a8a187becdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -122,6 +122,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() + awaitStarted = (sessionId: string): Promise => + this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() readOptions = (input: { sessionId: string; fence: number }) => { const reader = this.owner(input.sessionId).readOptions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index fb0971b6e903..0c44a31bbe4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -278,6 +278,9 @@ export type StructuredAgentSessionAdapter = { ): Promise>> /** Resolves once a live session can take an option write, or after a bound; never rejects. */ awaitOptionWritable?(sessionId: string): Promise + /** Resolves once a session published before it proved its start has proven it, failed, or been + * closed; at once for any other. Never rejects. */ + awaitStarted?(sessionId: string): Promise readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 1464ae5ae6ca..7f982718afac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -33,8 +33,9 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** Paired with `sessions.delete` by `forgetStructuredAgentSession`; a failed attach that only - * deleted would leave the store's row behind. */ + /** Paired with `sessions.delete` by `forgetStructuredAgentSession`, which a close runs. */ forgetStatus: (sessionId: string) => void publishStatus?: (sessionId: string) => void + /** The conversation's one open journal, opened when closed; see `conversation-open`. */ + openConversation: (sessionId: string) => Promise } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 3a77aa2d6cca..533f539466c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -24,8 +24,6 @@ export async function settlePostAcquisitionAttachFailure( ? 'root-exit-observed' : 'exit-proven' } - // A failed close must not prevent durable failure settlement. - await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined) try { await input.store.settleFailedPostAcquisitionAttachment({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index ab9c44949e00..83555d637294 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -41,6 +41,7 @@ import { type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -66,8 +67,8 @@ export type AttachFlowInput = { onAcquiring?: () => Promise | void /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void - /** Closes and removes partial publication after journal attachment fails. */ - onAttachFailed?: () => Promise + /** The host's open conversation, which the attach adopts rather than opening a second one. */ + openConversation?: (sessionId: string) => Promise } export async function performAttach( @@ -208,6 +209,7 @@ export async function performAttach( params, journalRoot: input.journalRoot, adapter: input.adapter, + ...(input.openConversation ? { openConversation: input.openConversation } : {}), providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index aba50a33ff42..26cf103d3621 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -24,9 +24,7 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { addAgentSessionCreatePhaseAttributes, @@ -162,12 +160,14 @@ async function runAttach( params, now: () => context.now(), recordPhase, - // Site 9: this closes the PRIOR map entry it drops, never the provisional - // journal — it has no reference to that one. `onAttached` owns that. - onAttachFailed: async () => { - await forgetStructuredAgentSession(context, sessionId) - context.runtimeState.currentEventSink(sessionId)?.close() - context.runtimeState.discardEventSink(sessionId) + // A failed attach leaves the conversation open, with its queued messages, for the failure + // to be written into; only the attempt's own sink is discarded, in the `finally` below. + openConversation: async (id) => { + const conversation = await context.openConversation(id) + if (!conversation) { + throw new Error('agent_session_identity_required') + } + return conversation.journal }, onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 @@ -177,37 +177,19 @@ async function runAttach( const eventSink = acquiredOwner ? attemptSink : (context.runtimeState.currentEventSink(sessionId) ?? attemptSink) - // Site 8: the provisional journal has no owner until the map takes it, - // and the barrier below throws by design. - try { - if (acquiredOwner) { - // Before the drain: the buffered events are the new child's, never a stale row's. - await settleStaleSessionStateOnAcquire({ - journal: attached.journal, - sessionId, - fence, - acquisitionGeneration - }) - } - await bindAndDrain(eventSink, attached.journal, fence, (activity) => - context.subscribers.publish(sessionId, attached.journal, activity) - ) - } catch (error) { - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) - throw error - } - // Site 10: a `set` over a live entry would orphan its handle — and a - // close that REJECTED did not release it. The replacement is therefore - // ABORTED rather than completed over a handle nothing can reach again: - // `previous` stays indexed, so teardown still owns it and can retry. - if (previous && previous.journal !== attached.journal) { - try { - await previous.journal.close() - } catch (error) { - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) - throw error - } + // The journal is the conversation's own open one, so a throw here leaves it indexed. + if (acquiredOwner) { + // Before the drain: the buffered events are the new child's, never a stale row's. + await settleStaleSessionStateOnAcquire({ + journal: attached.journal, + sessionId, + fence, + acquisitionGeneration + }) } + await bindAndDrain(eventSink, attached.journal, fence, (activity) => + context.subscribers.publish(sessionId, attached.journal, activity) + ) context.runtimeState.adoptEventSink(sessionId, eventSink) attemptSinkAdopted = eventSink === attemptSink context.sessions.set(sessionId, { @@ -230,9 +212,7 @@ async function runAttach( context.now ) await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) - if (attached.recovery) { - context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset) - } else if (previousFence !== undefined && previousFence !== fence) { + if (previousFence !== undefined && previousFence !== fence) { context.subscribers.snapshot(sessionId, attached.journal) } else { context.subscribers.publish(sessionId, attached.journal) @@ -241,12 +221,23 @@ async function runAttach( }).finally(() => { if (!attemptSinkAdopted) { attemptSink.close() + followRecordFence(context, sessionId) } }) ) return attached } +/** A failed attempt still moved the record's fence when it reserved; the conversation it leaves + * open writes its failure row and rejections at that fence, not the one it opened at. */ +function followRecordFence(context: StructuredAgentSessionAttachContext, sessionId: string): void { + const conversation = context.sessions.get(sessionId) + const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence + if (conversation && !conversation.hasProviderChild && fence !== undefined) { + conversation.fence = Math.max(conversation.fence, fence) + } +} + /** Binds the sink to the journal and waits for the barrier the host publishes * behind. It throws by design when a sink barrier fails. */ async function bindAndDrain( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 97f429933d87..81fb5f927384 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -147,4 +147,36 @@ describe('attachJournal restart reconciliation', () => { expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') }) + + it('leaves a message the open conversation still has queued alone (W4′e)', async () => { + const journal = await journals.open({ + identity: IDENTITY, + journalDir: journalDirectoryFor(root, { + workspaceId: IDENTITY.workspaceId, + sessionId: IDENTITY.sessionId + }) + }) + await journal.appendSubmission({ + clientMessageId: 'queued', + payloadFingerprint: digestPayload('still queued'), + body: userMessage('still queued'), + fence: RECORD.lease.runtimeFence, + handoverRecorded: true + }) + // History that holds nothing: absence would prove a handed-over message undelivered. + const { adapter, dispatch } = adapterWith(async () => window()) + + const attached = await attachJournal({ + record: RECORD, + params: PARAMS, + journalRoot: root, + adapter, + openConversation: async () => journal + }) + + expect(attached.journal).toBe(journal) + expect(journal.submissions()[0]).toMatchObject({ dispatchState: 'pending' }) + expect(journal.submissions()[0]?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 2e42ffe9893d..6b75b159a332 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -36,15 +36,10 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { - openAgentSessionJournalWithRecovery, - type AgentSessionJournalRecovery -} from './agent-session-journal-recovery' +import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' @@ -166,16 +161,13 @@ export function journalIdentityFor( export type AttachedJournal = { journal: AgentSessionJournal - recovery: AgentSessionJournalRecovery | null - /** Submissions still `unknown` after this open: the crash boundary settled - * them there and provider history could not decide them either. */ + /** Submissions the crash boundary left `unknown` that provider history could not decide. */ unconfirmedClientMessageIds: string[] } /** - * Open the session's journal, recovering it when the stored one is unusable, - * settle every submission left in flight by a previous process, then let - * provider history decide the ones it can prove. + * The conversation's journal — opened, and its crash boundary settled, by the conversation's own + * open — with provider history deciding the submissions that boundary could only doubt. * * Why the reconciliation belongs HERE and nowhere else: this runs after the * record store handed this host the lease and before `onAttached` starts a @@ -183,57 +175,58 @@ export type AttachedJournal = { * is read, and the window stays valid until the resume consumes it. Every other * settlement site — a proven child exit — runs while the host * may still start another child, and a read there could be overtaken before it - * is acted on. Orca still never re-sends: this decides state only. + * is acted on. Orca still never re-sends: this decides state only. A queued + * submission is left alone: it was never handed over, so history cannot hold it. */ export async function attachJournal(input: { record: AgentSessionRecord params: AgentSessionAttachParams journalRoot: string adapter: StructuredAgentSessionAdapter + /** The host's open conversation; omitted, the journal is opened for this caller alone. */ + openConversation?: (sessionId: string) => Promise /** Provider history sampled before a new child is acquired. `null` means the * adapter had no usable history; omit to read lazily for direct callers. */ providerHistoryWindow?: ProviderHistoryWindow | null }): Promise { const identity = journalIdentityFor(input.record, input.params) const fence = input.record.lease.runtimeFence - const historyFilePath = input.adapter.historyFilePath - ? await input.adapter.historyFilePath({ identity }) - : null - const opened = await openAgentSessionJournalWithRecovery({ + const journal = input.openConversation + ? await input.openConversation(input.record.sessionId) + : await openJournalForCaller(input) + const settled = await reconcileAgainstProviderHistory({ + adapter: input.adapter, identity, - journalDir: journalDirectoryFor(input.journalRoot, { - workspaceId: identity.workspaceId, - sessionId: identity.sessionId - }), + journal, fence, - historyFilePath + accountHome: input.record.accountHome, + ...(Object.hasOwn(input, 'providerHistoryWindow') + ? { history: input.providerHistoryWindow } + : {}) }) - try { - // That await is a WRITE. A failure in it leaves the journal with no caller - // holding a reference to close it. - const unconfirmed = await opened.journal.markPendingSubmissionsUnknown(fence) - const settled = await reconcileAgainstProviderHistory({ - adapter: input.adapter, - identity, - journal: opened.journal, - fence, - accountHome: input.record.accountHome, - ...(Object.hasOwn(input, 'providerHistoryWindow') - ? { history: input.providerHistoryWindow } - : {}) - }) - return { - ...opened, - unconfirmedClientMessageIds: unconfirmed.filter((id) => !settled.includes(id)) - } - } catch (error) { - // A rejected close leaves the handle open, so the journal is retained for a - // later retry rather than dropped along with the only reference to it. - await agentSessionJournalCloseRetries.closeOrRetain(opened.journal) - throw error + return { + journal, + unconfirmedClientMessageIds: journal + .submissions() + .filter( + (entry) => + entry.dispatchState === 'unknown' && + entry.recovered === true && + !settled.includes(entry.clientMessageId) + ) + .map((entry) => entry.clientMessageId) } } +async function openJournalForCaller(input: { + record: AgentSessionRecord + journalRoot: string + adapter: StructuredAgentSessionAdapter +}): Promise { + const opened = await openStructuredAgentSessionConversationJournal(input, input.record) + return opened.session.journal +} + /** Reading provider history is best effort: a provider that reports none, or a * read that fails, leaves every submission exactly as the crash boundary wrote * it. The journal writes the outcome implies are NOT caught here — a failed diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts index b9e8f8e506d7..8c99be697c51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts @@ -178,40 +178,34 @@ describe('the registry', () => { }) describe('the attach orchestration', () => { - it('ABORTS the map replacement when the previous journal will not close', async () => { - const previousDir = join(root, 'previous') - const provisionalDir = join(root, 'provisional') - const previous = flakyClose( - await journals.open({ identity: IDENTITY, journalDir: previousDir }), - 1 - ) - const provisional = await journals.open({ - identity: IDENTITY, - journalDir: provisionalDir - }) - attachFlow.journal = provisional - const sessions = new Map([[SESSION, hostSession(previous)]]) + // The attach adopts the conversation's one open journal; it never opens a second handle, so + // there is no replacement to abort and no provisional journal to close. + it('keeps the journal it adopted indexed and open when an attach succeeds', async () => { + const directory = join(root, 'adopted') + const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) + attachFlow.journal = journal + const sessions = new Map([[SESSION, hostSession(journal)]]) - await expect( - attachStructuredAgentSession(attachContext(sessions), 'caller-1', attachParams) - ).rejects.toThrow('close rejected') + await attachStructuredAgentSession(attachContext(sessions), 'caller-1', attachParams) - // The live entry is UNTOUCHED: overwriting it would have left its handle - // open with nothing able to reach it again. - expect(sessions.get(SESSION)?.journal).toBe(previous) - // And the provisional journal is owned by the registry, not orphaned. + expect(sessions.get(SESSION)?.journal).toBe(journal) + await expect( + journal.appendItem( + { provider: 'orca', clientMessageId: 'after-attach' }, + { + kind: 'status', + text: 'still writable' + } + ) + ).resolves.toBeDefined() expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(provisionalDir) }) - it('retains the provisional journal when its own close rejects on the barrier path', async () => { - const provisionalDir = join(root, 'provisional-barrier') - const provisional = flakyClose( - await journals.open({ identity: IDENTITY, journalDir: provisionalDir }), - 1 - ) - attachFlow.journal = provisional - const sessions = new Map() + it('leaves the conversation indexed and open when the sink barrier fails', async () => { + const directory = join(root, 'adopted-barrier') + const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) + attachFlow.journal = journal + const sessions = new Map([[SESSION, hostSession(journal)]]) const context = attachContext(sessions) const failing = { sink: {}, @@ -229,9 +223,19 @@ describe('the attach orchestration', () => { 'sink barrier failed' ) - expect(sessions.size).toBe(0) - // Retained rather than dropped, so teardown can still release the handle. - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([provisionalDir]) + // A failed attach does not end the conversation: its queued messages and the failure row + // are written into this same journal. + expect(sessions.get(SESSION)?.journal).toBe(journal) + await expect( + journal.appendItem( + { provider: 'orca', clientMessageId: 'after-failure' }, + { + kind: 'status', + text: 'still writable' + } + ) + ).resolves.toBeDefined() + expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts new file mode 100644 index 000000000000..5b20c537cfc1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -0,0 +1,153 @@ +// The one way a conversation's journal becomes open on this host: for a send, for a reader, and +// for an attach that finds none open. +// +// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it settles +// what an earlier host process left unanswered — the crash boundary. That needs no lease: a row +// queued there was accepted by a process that ended before handing it over, so it is provably +// unwritten; a handed-over row is only doubt, and provider history decides it later, under a won +// lease, in the attach. Nothing here starts a provider child. + +import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + attachFingerprintFields, + journalIdentityFor, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' + +export type OpenedStructuredAgentSessionConversation = { + session: StructuredAgentSessionHostSession + /** Set when the journal was rebuilt on the way; readers reload from a snapshot. */ + reset: AgentJournalResetReason | null +} + +export type StructuredAgentSessionConversationOpenDeps = { + store: Pick + adapter: Pick + journalRoot: string + onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] +} + +export type StructuredAgentSessionConversationOpenContext = { + deps: StructuredAgentSessionConversationOpenDeps + sessions: Map + /** Indexes a conversation that just became open; the host publishes it and wakes delivery. */ + adoptOpened: ( + sessionId: string, + opened: OpenedStructuredAgentSessionConversation + ) => Promise +} + +/** The open conversation, or null when this host has no record of it. For a caller inside the + * session's serialize, which is what makes "not open yet" exact. */ +export async function openStructuredAgentSessionConversation( + context: StructuredAgentSessionConversationOpenContext, + sessionId: string +): Promise { + const open = context.sessions.get(sessionId) + if (open) { + return open + } + const record = context.deps.store.getRecord(sessionId) + if (!record) { + return null + } + const opened = await openStructuredAgentSessionConversationJournal(context.deps, record) + await context.adoptOpened(sessionId, opened) + return opened.session +} + +/** The open itself, indexed by nobody yet: the caller adopts the result. */ +export async function openStructuredAgentSessionConversationJournal( + deps: Omit, + record: AgentSessionRecord +): Promise { + const { sessionId } = record + const fence = record.lease.runtimeFence + const params = attachParamsForRecord(record, { + clientOperationId: `read-restore:${sessionId}`, + expectedRuntimeFence: fence + }) + const identity = journalIdentityFor(record, params) + const opened = await openAgentSessionJournalWithRecovery({ + identity, + journalDir: journalDirectoryFor(deps.journalRoot, { + workspaceId: record.location.workspaceId, + sessionId + }), + fence, + historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null + }) + try { + await settleEarlierProcessSubmissions(opened.journal, fence) + } catch (error) { + // Best effort: the delivery loop rejects a leftover queued row itself. + deps.onEventSinkError?.({ sessionId, error }) + } + return { + session: { + journal: opened.journal, + params, + fence, + hasProviderChild: false, + providerChildPhase: 'ready', + acquisitionGeneration: null + }, + reset: opened.recovery?.reset ?? null + } +} + +async function settleEarlierProcessSubmissions( + journal: AgentSessionJournal, + fence: number +): Promise { + // A handle closes only with nothing queued, so a queued row found here is a leftover. + await journal.rejectQueuedSubmissions(fence, DISPATCH_REJECTED_HOST_RESTARTED, (submission) => + journal.wroteBeforeOpen(submission.acceptedSequence) + ) + await journal.markPendingSubmissionsUnknown(fence) +} + +export function attachParamsForRecord( + record: AgentSessionRecord, + input: { + clientOperationId: string + expectedRuntimeFence: number + } +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: record.sessionId, + clientOperationId: input.clientOperationId, + expectedRuntimeFence: input.expectedRuntimeFence, + payloadFingerprint: '' + }, + location: record.location, + provider: record.provider, + agent: record.provider, + accountHome: record.accountHome, + runtimeKind: 'native' + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: record.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index d553fbaecf37..6d7d8618ad43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -190,6 +190,7 @@ describe('dead structured-session generation settlement', () => { | 'submissions' | 'markPendingSubmissionsUnknown' | 'rejectPendingSubmissions' + | 'rejectQueuedSubmissions' | 'appendLifecycleBatch' > = { snapshot: () => ({ @@ -203,6 +204,9 @@ describe('dead structured-session generation settlement', () => { rejectPendingSubmissions: async () => { throw new Error('journal_closed') }, + rejectQueuedSubmissions: async () => { + throw new Error('journal_closed') + }, appendLifecycleBatch: async () => { throw new Error('journal_closed') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 1b5f73fdbdae..aa7d8ea36f36 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -4,6 +4,7 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { @@ -55,6 +56,14 @@ export function providerStartupFailureOutcome(reason?: string): string { : 'The provider stopped before it finished starting.' } +/** Why a message accepted but not yet handed over was rejected when its child exited. */ +export function providerExitBeforeDeliveryRejection(reason?: string): string { + const detail = exitReasonDetail(reason) + return detail + ? `The provider stopped before this message was sent: ${detail}.` + : 'The provider stopped before this message was sent.' +} + /** Why a send a child that never started left unwritten was rejected. The child's own diagnostic is * the cause the user can act on, so it is the reason, in the words the chat row uses. */ export function providerStartupFailureRejection(cause?: unknown): string { @@ -72,13 +81,14 @@ function exitReasonDetail(reason: string | undefined): string | undefined { type DeadGenerationSubmission = Pick< ReturnType[number], - 'clientMessageId' | 'dispatchState' | 'recovered' + 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' > export type DeadGenerationJournal = { appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] + rejectQueuedSubmissions: AgentSessionJournal['rejectQueuedSubmissions'] snapshot: () => Pick, 'items'> pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] submissions?: () => DeadGenerationSubmission[] @@ -152,9 +162,18 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return true } - // A child that never proved its start accepted nothing — input is written only after it - // initializes — so every send it left unanswered is provably unwritten and is rejected with the - // child's own diagnostic. A proven child's unanswered sends stay in doubt. + // A queued message was never handed to this child, so it is provably unwritten. A child + // that never proved its start accepted nothing either — input is written only after it + // initializes — so every send it left unanswered is rejected with the child's own diagnostic. + // A proven child's handed-over sends stay in doubt. + await input.journal.rejectQueuedSubmissions( + input.fence, + input.exitedDuringStartup + ? providerStartupFailureRejection(input.unexpectedExitReason) + : input.unexpectedExitReason === undefined + ? DISPATCH_REJECTED_PROVIDER_CLOSED + : providerExitBeforeDeliveryRejection(input.unexpectedExitReason) + ) await (input.exitedDuringStartup ? input.journal.rejectPendingSubmissions( input.fence, @@ -247,7 +266,9 @@ function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { return submissions ? submissions.some( (submission) => - submission.dispatchState === 'pending' || + // A queued message is not work in progress: nothing has it yet. + (submission.dispatchState === 'pending' && + !(submission.handoverRecorded && submission.handedOverAt === undefined)) || (submission.dispatchState === 'unknown' && submission.recovered !== true) ) : (journal.pendingSubmissions?.().length ?? 0) > 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts new file mode 100644 index 000000000000..7bdc65119c62 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -0,0 +1,152 @@ +// The one thing that starts a provider child for a send, and the one thing that hands a message +// to it. +// +// A send is accepted on its own serialized step and returns; this loop does the rest. It exists +// for a session exactly while a message is queued there — accepted, not yet handed over — and +// every step re-reads the journal to decide, so there is no loop state to disagree with it. +// Each step is its own serialized task. That is what lets a Stop that arrives while a start holds +// the queue withdraw the queued messages before the handover that would have written them. + +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + oldestQueuedSubmission, + recordStructuredAgentSessionStartFailure +} from './structured-agent-session-start-failure-row' +import { handOverSubmission } from './structured-agent-session-turns' + +export type StructuredAgentSessionDeliveryLoopDeps = { + sessions: ReadonlyMap + adapter: StructuredAgentSessionAdapter + serialize: (sessionId: string, task: () => Promise) => Promise + /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ + ensureProviderChild: (sessionId: string) => Promise + /** What the chat says when the session could not be made ready. */ + startFailureText: (sessionId: string, cause: AgentSessionWireRefusal) => string + onError: (sessionId: string, error: unknown) => void +} + +type Step = 'continue' | 'stop' + +export class StructuredAgentSessionDeliveryLoop { + private readonly running = new Set() + private disposed = false + + constructor(private readonly deps: StructuredAgentSessionDeliveryLoopDeps) {} + + isRunning(sessionId: string): boolean { + return this.running.has(sessionId) + } + + /** Quit: no step after this one starts a child or hands a message over; what is still queued + * is left for the next open, which rejects it as never sent. */ + dispose(): void { + this.disposed = true + } + + /** From inside the session's serialize, after a message was accepted or the conversation + * opened. A loop already running re-reads the journal on its next step. */ + wake(sessionId: string): void { + if (this.disposed || this.running.has(sessionId)) { + return + } + this.running.add(sessionId) + void this.run(sessionId) + } + + private async run(sessionId: string): Promise { + try { + for (;;) { + const ready = await this.deps.serialize(sessionId, () => this.prepare(sessionId)) + if (ready === 'stop') { + return + } + if (!ready.ok) { + const text = this.deps.startFailureText(sessionId, ready.refusal) + await this.deps.serialize(sessionId, () => this.fail(sessionId, text)) + return + } + // A child published before it proved its start takes no input yet; waited for outside + // the queue so a Stop can reach it meanwhile. + await this.deps.adapter.awaitStarted?.(sessionId) + if ((await this.deps.serialize(sessionId, () => this.handOver(sessionId))) === 'stop') { + return + } + } + } catch (error) { + this.deps.onError(sessionId, error) + const text = this.deps.startFailureText(sessionId, { + code: 'agent_session_owner_restart_failed', + message: error instanceof Error ? error.message : String(error) + }) + await this.deps + .serialize(sessionId, () => this.fail(sessionId, text)) + .catch((failure: unknown) => { + // Rows left queued are rejected by the next open, or by the next loop an accept wakes. + this.running.delete(sessionId) + this.deps.onError(sessionId, failure) + }) + } + } + + /** Settles what an earlier host process left queued, then makes the session ready. */ + private async prepare(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || this.disposed) { + return this.stop(sessionId) + } + await session.journal.rejectQueuedSubmissions( + session.fence, + DISPATCH_REJECTED_HOST_RESTARTED, + // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. + (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) + ) + if (!oldestQueuedSubmission(session)) { + return this.stop(sessionId) + } + return this.deps.ensureProviderChild(sessionId) + } + + private async handOver(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || this.disposed) { + return this.stop(sessionId) + } + // Re-derived here, not carried from the start: the child may have gone since. + if (!session.hasProviderChild) { + return 'continue' + } + const next = oldestQueuedSubmission(session) + if (!next) { + return this.stop(sessionId) + } + await handOverSubmission( + { + sessionId, + journal: session.journal, + fence: session.fence, + adapter: this.deps.adapter, + providerChildPhase: () => this.deps.sessions.get(sessionId)?.providerChildPhase + }, + next + ) + return 'continue' + } + + private async fail(sessionId: string, text: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (session) { + await recordStructuredAgentSessionStartFailure(session, text) + } + return this.stop(sessionId) + } + + /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ + private stop(sessionId: string): 'stop' { + this.running.delete(sessionId) + return 'stop' + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 20531bd64653..304cadf8f860 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -21,11 +21,10 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' -// Everything before the journal is out of scope here; what matters is that the orchestration's -// own `onAttachFailed` runs, which is the real one. +// Everything before the journal is out of scope here; what matters is what the orchestration does +// when the attach throws after acquisition. vi.mock('./structured-agent-session-attach-flow', () => ({ - performAttach: async (input: { onAttachFailed?: () => Promise }) => { - await input.onAttachFailed?.() + performAttach: async () => { throw new Error('attach failed after acquisition') } })) @@ -230,15 +229,19 @@ describe('a session that leaves the host without an explicit close', () => { expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ prompt: 'other host' })]) }) - it('leaves the agent-status store with it when an attach fails', async () => { + // A failed attach no longer drops the session: the conversation stays open for the failure to be + // written into, so its row stays with it and the later close forgets both together. + it('keeps the session and its status row together when an attach fails', async () => { const { server, feed, sessions } = await workingSession() + const drop = vi.spyOn(server, 'dropStructuredStatus') await expect( attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) ).rejects.toThrow('attach failed after acquisition') - expect(sessions.has(SESSION)).toBe(false) - expect(server.getStatusSnapshot()).toEqual([]) + expect(sessions.has(SESSION)).toBe(true) + expect(drop).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toHaveLength(1) }) // The feed's own cache deliberately retains the projection for reload history; only the store 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 new file mode 100644 index 000000000000..3065c464329f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -0,0 +1,91 @@ +// The host's conversations: how one becomes open, and the delivery loop that hands its accepted +// messages to a provider child. Bundled because they share one invariant — a conversation open +// with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. + +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openStructuredAgentSessionConversation, + type OpenedStructuredAgentSessionConversation +} from './structured-agent-session-conversation-open' +import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { structuredAgentSessionStartFailureText } from './structured-agent-session-send-preparation' +import { settleInterruptedCompaction } from './structured-compaction-recovery' +import { recoverStructuredRewind } from './structured-rewind-recovery' + +export type StructuredAgentSessionConversationDelivery = { + loop: StructuredAgentSessionDeliveryLoop + /** For a caller inside the session's serialize. */ + open: (sessionId: string) => Promise + /** Indexes a conversation some other open produced, as `open` would have. */ + adoptOpened: ( + sessionId: string, + opened: OpenedStructuredAgentSessionConversation + ) => Promise +} + +export function createStructuredAgentSessionConversationDelivery(input: { + deps: StructuredAgentSessionHostDeps + sessions: Map + serialize: (sessionId: string, task: () => Promise) => Promise + ensureProviderChild: (sessionId: string) => Promise + reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void + publishRestored: (sessionId: string) => void +}): StructuredAgentSessionConversationDelivery { + const { deps, sessions } = input + const loop = new StructuredAgentSessionDeliveryLoop({ + sessions, + adapter: deps.adapter, + serialize: input.serialize, + ensureProviderChild: input.ensureProviderChild, + startFailureText: (sessionId, cause) => + structuredAgentSessionStartFailureText(deps.store.getRecord(sessionId), cause), + onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + }) + const adoptOpened = async ( + sessionId: string, + opened: OpenedStructuredAgentSessionConversation + ): Promise => { + const { session, reset } = opened + sessions.set(sessionId, session) + if (reset) { + input.reset(sessionId, session.journal, reset) + } + input.publishRestored(sessionId) + await settleInterruptedCommands(deps, sessionId, session) + if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { + loop.wake(sessionId) + } + } + return { + loop, + adoptOpened, + open: (sessionId) => + openStructuredAgentSessionConversation({ deps, sessions, adoptOpened }, sessionId) + } +} + +/** + * A compaction or rewind found prepared when the conversation opens was started under a child + * this process no longer has — the open runs only when none is indexed — so nothing will finish + * it, and left alone it refuses every send until a view attaches. Settled here instead of by a + * start inside acceptance. A Codex rewind only its provider can prove stays for the attach. + */ +async function settleInterruptedCommands( + deps: StructuredAgentSessionHostDeps, + sessionId: string, + session: StructuredAgentSessionHostSession +): Promise { + try { + await settleInterruptedCompaction(deps.store, sessionId, session.journal, session.fence) + await recoverStructuredRewind(deps.store, sessionId, session.journal, session.fence) + } catch (error) { + deps.onEventSinkError?.({ sessionId, error }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 00df8b4e9104..39f0e4415df7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -6,6 +6,8 @@ // do. import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { DISPATCH_REJECTED_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, @@ -38,12 +40,18 @@ export type StructuredAgentSessionLifetimeContext = { } /** Dropping a session and dropping its status row are ONE operation: the store keeps the row until - * told, so a caller that only deletes strands a live-looking row no reader can ever decay. */ + * told, so a caller that only deletes strands a live-looking row no reader can ever decay. A + * handle closes with nothing queued: what is still queued now will not be handed over. */ export async function forgetStructuredAgentSession( context: StructuredAgentSessionLifetimeContext, sessionId: string ): Promise { - await context.sessions.get(sessionId)?.journal.close() + const session = context.sessions.get(sessionId) + await session?.journal + .rejectQueuedSubmissions(session.fence, DISPATCH_REJECTED_PROVIDER_CLOSED) + // Best effort: the next open rejects a leftover itself. + .catch((error: unknown) => context.deps.onEventSinkError?.({ sessionId, error })) + await session?.journal.close() context.sessions.delete(sessionId) context.forgetStatus(sessionId) } @@ -175,7 +183,8 @@ export async function evictOwnedStructuredAgentSessions( * resume and a send's ensure-owner step are the same serialized attach with a different asker. */ export function createStructuredAgentSessionHolds( attachContext: () => StructuredAgentSessionAttachContext, - close: (sessionId: string) => Promise + close: (sessionId: string) => Promise, + deliveryActive: (sessionId: string) => boolean ): StructuredAgentSessionHolds { const context = attachContext() return new StructuredAgentSessionHolds({ @@ -196,12 +205,15 @@ export function createStructuredAgentSessionHolds( }, evict: close, hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), - // A send pending while the child is still starting is held for that start; evicting would - // refuse it. Any other pending send may wait on an echo that never comes, so eviction retires it. + // A message accepted and not yet handed over is owed to this child, and so is one pending while + // the child still starts. Any other pending send may wait on an echo that never comes, so + // eviction retires it. hasOwedWork: (sessionId) => { const session = context.sessions.get(sessionId) return session ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null || + deliveryActive(sessionId) || + session.journal.submissions().some(isQueuedAgentJournalSubmission) || (session.providerChildPhase === 'starting' && session.journal.pendingSubmissions().length > 0) : false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 906025413a9a..971c63aa6fca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -3,8 +3,8 @@ // // They share one shape — admit the envelope against the lease, run a plan, publish the journal — so // they share one path here rather than five copies in the host. The host keeps attach, holds and -// teardown. A send is the one mutation that may need those first: it makes sure the session has -// an owner as a step of its own serialized admission, see `structured-agent-session-send-preparation`. +// teardown. A send and a Stop are conversation writes: they open the conversation and are admitted +// without the writer lease; the session's delivery loop starts the provider child a send needs. import type { AgentJournalItemIdentity, @@ -21,14 +21,14 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionHolds } from './structured-agent-session-holds' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { admitAndRunAgentSessionMutation, type AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' import { - prepareStructuredAgentSessionSend, + openConversationForWrite, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { @@ -51,11 +51,12 @@ export type StructuredAgentSessionMutationContext = { flushStreamedEvents: (sessionId: string) => Promise requireSession: (sessionId: string) => StructuredAgentSessionHostSession serialize: (sessionId: string, task: () => Promise) => Promise - /** A send that finds the owner gone brings it back through here, inside its own serialize. */ - holds: Pick - /** Makes a closed session's journal readable again, inside the caller's serialize, for a send - * the ledger answers without an owner. */ - restoreReadable: (sessionId: string) => Promise + /** The session's conversation, opened when closed; inside the caller's serialize. */ + openConversation: (sessionId: string) => Promise + /** A message was accepted: the session's delivery loop hands it over. */ + wakeDelivery: (sessionId: string) => void + /** Stops a provider child that has not proven its start; inside the caller's serialize. */ + stopStartingChild: (sessionId: string) => Promise now: () => number } @@ -100,12 +101,19 @@ export function sendStructuredAgentSessionTurn( params.envelope, { ...plan, - run: (ctx) => { + run: async (ctx) => { const blocked = structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) - return blocked ? Promise.resolve(blocked) : plan.run(ctx) + if (blocked) { + return blocked + } + const accepted = await plan.run(ctx) + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted } }, - (ledger, record) => prepareStructuredAgentSessionSend(context, params.envelope, ledger, record) + () => openConversationForWrite(context.openConversation, params.envelope) ) } @@ -130,7 +138,35 @@ export function cancelStructuredAgentSessionTurn( context.serialize(`compact-cancel:${sessionId}`, task) } : context - return mutate(cancellationContext, caller, params.envelope, cancelPlan(params)) + const plan = cancelPlan(params) + if (params.scope || params.prompt) { + return mutate(cancellationContext, caller, params.envelope, plan) + } + return mutate( + cancellationContext, + caller, + params.envelope, + { + ...plan, + run: async (ctx) => { + // Stop withdraws every queued message first, whatever the start or the child is doing. + const withdrawn = await ctx.journal.rejectQueuedSubmissions( + ctx.fence, + DISPATCH_REJECTED_CANCELLED + ) + const session = context.sessions.get(ctx.sessionId) + if (session?.hasProviderChild && session.providerChildPhase === 'starting') { + // A start that may never land is the one thing here Stop has to end. + await context.stopStartingChild(ctx.sessionId) + return { ok: true, value: { turnId: params.turnId, cancelled: true } } + } + return session?.hasProviderChild + ? plan.run(ctx) + : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } + } + }, + () => openConversationForWrite(context.openConversation, params.envelope) + ) } export function respondToStructuredAgentSessionPrompt( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 62f88672342c..f85919267660 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -766,9 +766,19 @@ describe('subscribe', () => { body }) - expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) - expect(dispatch).toHaveBeenCalledTimes(1) - expect(events.some((event) => event.type === 'batch')).toBe(true) + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // The failed transport does not stop the delivery loop either: the handover still lands and + // reaches the live subscriber. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.submissions?.some((entry) => entry.dispatchState === 'accepted') + ) + ).toBe(true) + ) }) it('resets a subscriber whose epoch is gone', async () => { 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 d63e1ad8335f..ba059204cb17 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 @@ -52,6 +52,7 @@ import { type StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' +import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -77,6 +78,9 @@ export class StructuredAgentSessionHost { ) => Promise private readonly restore: ReturnType private readonly holds: StructuredAgentSessionHolds + private readonly conversationDelivery: ReturnType< + typeof createStructuredAgentSessionConversationDelivery + > private readonly eventRecovery: StructuredAgentSessionEventRecovery private readonly backgroundTasks: StructuredAgentSessionBackgroundTaskChannel /** Public because the RPC surface addresses it directly; see the restart-resume collaborator. */ @@ -99,9 +103,18 @@ export class StructuredAgentSessionHost { ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), now: () => this.now() }) + this.conversationDelivery = createStructuredAgentSessionConversationDelivery({ + deps, + sessions: this.sessions, + serialize: (sessionId, task) => this.serialize(sessionId, task), + ensureProviderChild: (sessionId) => this.holds.ensureProviderChild(sessionId), + reset: (...args) => this.subscribers.reset(...args), + publishRestored: this.clientDelivery.publishRestored + }) this.holds = createStructuredAgentSessionHolds( () => this.attachContext(), - (sessionId) => this.close(sessionId) + (sessionId) => this.close(sessionId), + (sessionId) => this.conversationDelivery.loop.isRunning(sessionId) ) this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { reconcile: this.reconcileLeases, @@ -110,10 +123,7 @@ export class StructuredAgentSessionHost { hasSession: this.hasSession, // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. - onReadable: (sessionId, restored) => { - this.sessions.set(sessionId, restored) - this.clientDelivery.publishRestored(sessionId) - } + onReadable: this.conversationDelivery.adoptOpened }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, @@ -175,18 +185,21 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - publishStatus: this.clientDelivery.publishStatus + publishStatus: this.clientDelivery.publishStatus, + openConversation: this.conversationDelivery.open } } /** Releases a session's resources without ending the conversation: the record and journal stay * on disk, so the same session can be attached again. */ close(sessionId: string): Promise { - return this.serialize(sessionId, async () => { - await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) - this.clientDelivery.closeSession(sessionId) - // The holders now look at a session that is gone; a failed eviction throws above, keeping them. - this.holds.forget(sessionId) - }) + return this.serialize(sessionId, () => this.closeUnderSerialize(sessionId)) + } + + private async closeUnderSerialize(sessionId: string): Promise { + await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) + this.clientDelivery.closeSession(sessionId) + // The holders now look at a session that is gone; a failed eviction throws above, keeping them. + this.holds.forget(sessionId) } supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => @@ -227,6 +240,7 @@ export class StructuredAgentSessionHost { // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { + this.conversationDelivery.loop.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), holds: this.holds, @@ -245,8 +259,9 @@ export class StructuredAgentSessionHost { flushStreamedEvents: this.flushStreamedEvents, requireSession: (sessionId) => this.requireSession(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - holds: this.holds, - restoreReadable: (sessionId) => this.restore.restoreReadableUnderSerialize(sessionId), + openConversation: this.conversationDelivery.open, + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), + stopStartingChild: (sessionId) => this.closeUnderSerialize(sessionId), now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 1874f7ee1c67..6bda0b661898 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -71,6 +71,18 @@ function journal(): AgentSessionJournal { ).sessions.get(SESSION)!.journal } +/** A send is accepted first; this waits for the delivery loop to hand it to the provider. */ +async function handedOver(clientMessageId: string): Promise { + await vi.waitFor(() => { + expect( + journal() + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId)?.handedOverAt + ).toBeDefined() + expect(dispatch).toHaveBeenCalled() + }) +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wire-late-settle-')) resetHostTestOperationIds() @@ -152,10 +164,13 @@ describe('settling a send the provider proves it received after the ack window', finishDispatch({ state: 'unknown', reason: 'ack timeout' }) unsubscribe() } - await expect(pending).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'accepted' } } - }) + await expect(pending).resolves.toMatchObject({ ok: true }) + // The late `unknown` from the handover does not reopen the proven acceptance. + await vi.waitFor(() => + expect(submissions()).toMatchObject([ + { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } + ]) + ) await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } @@ -167,6 +182,8 @@ describe('settling a send the provider proves it received after the ack window', dispatch.mockResolvedValueOnce({ state: 'unknown', reason: 'ack timeout' }) const params = sendParams('received just before shutdown') await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) + await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'unknown' }])) let settlement: Promise | undefined closeSession.mockImplementationOnce(async () => { settlement = host.settleLateDispatch({ @@ -188,8 +205,9 @@ describe('settling a send the provider proves it received after the ack window', it('moves a durable unknown to accepted so nothing offers to send it again', async () => { dispatch.mockRejectedValueOnce(new Error('socket closed')) const params = sendParams('sent while a turn was running') - const first = await host.send(CALLER, params) - expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) + await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'unknown' }])) await host.settleLateDispatch({ sessionId: SESSION, @@ -209,6 +227,7 @@ describe('settling a send the provider proves it received after the ack window', dispatch.mockResolvedValueOnce({ state: 'admitted' }) const params = sendParams('queued behind the active turn') await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) await host.settleLateDispatch({ sessionId: SESSION, @@ -230,6 +249,7 @@ describe('settling a send the provider proves it received after the ack window', dispatch.mockResolvedValueOnce({ state: 'admitted' }) const params = sendParams('settle from provider echo') await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) vi.spyOn(journal(), 'resolveDispatch').mockRejectedValueOnce( new Error('direct settlement write failed') ) @@ -259,6 +279,7 @@ describe('settling a send the provider proves it received after the ack window', it('leaves an already accepted send alone', async () => { const params = sendParams('ordinary send') await host.send(CALLER, params) + await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }])) await host.settleLateDispatch({ sessionId: SESSION, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index 300f1423cb3c..e7bd9a9ac85f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -57,12 +57,20 @@ async function launchAndDeliver(): Promise<{ text: 'fix the failing test' }) const sent = await send.mock.results[0]!.value - return { - messageId, - dispatchState: sent.ok - ? sent.value.submission.dispatchState - : `refused:${sent.refusal.code}:${sent.refusal.message}` + if (!sent.ok) { + return { messageId, dispatchState: `refused:${sent.refusal.code}:${sent.refusal.message}` } } + // Accepted first; the delivery loop hands it over, and that outcome is what reached the agent. + let dispatchState = sent.value.submission.dispatchState + await vi.waitFor(() => { + dispatchState = + host + .journalSnapshot(created.value.sessionId) + .submissions.find((entry) => entry.clientMessageId === sent.value.clientMessageId) + ?.dispatchState ?? 'missing' + expect(dispatchState).not.toBe('pending') + }) + return { messageId, dispatchState } } beforeEach(() => { 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 42ffe172cd70..3563b506db26 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 @@ -25,12 +25,18 @@ import { const CALLER = { callerKey: 'client-1' } +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => void): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock let probe: Mock<() => Promise> let stopOwnerProcess: Mock<(pid: number, signal: 'SIGTERM' | 'SIGKILL') => void> +let dispatch: Mock function openHost(): void { host = new StructuredAgentSessionHost({ @@ -39,7 +45,7 @@ function openHost(): void { acquire, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + dispatch, cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -65,6 +71,24 @@ async function persistFromOlderBuild(lease: Partial) openHost() } +/** A send is accepted at once; what became of it is the submission's state once the host's + * delivery settles it — handed over, or rejected with the reason the chat shows. */ +async function delivered(text: string) { + const sent = await send(text) + expect(sent).toMatchObject({ ok: true }) + const clientMessageId = sent.ok ? sent.value.clientMessageId : '' + const submission = () => + host + .journalSnapshot(SESSION) + .submissions.find((candidate) => candidate.clientMessageId === clientMessageId) + await eventually(() => + expect( + submission()?.dispatchState !== 'pending' || submission()?.handedOverAt !== undefined + ).toBe(true) + ) + return submission() +} + async function send(text: string) { const body = hostTestMessage(text) return host.send(CALLER, { @@ -87,6 +111,7 @@ beforeEach(async () => { resetHostTestOperationIds() probe = vi.fn(async () => ({ outcome: 'pid-absent' as const })) stopOwnerProcess = vi.fn() + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) acquire = vi.fn(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { @@ -137,7 +162,8 @@ describe('a record an older build left mid terminal handoff', () => { handoffOperationId: null, settlementRetryRequired: undefined }) - expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(await delivered('after the upgrade')).toMatchObject({ dispatchState: 'pending' }) + expect(dispatch).toHaveBeenCalledOnce() expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', @@ -169,7 +195,8 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: null, handoffOperationId: null }) - expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(await delivered('after the upgrade')).toMatchObject({ dispatchState: 'pending' }) + expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) }) @@ -188,7 +215,8 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: null, claimStatus: 'released' }) - expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(await delivered('after the upgrade')).toMatchObject({ dispatchState: 'pending' }) + expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', claimStatus: 'live' @@ -210,10 +238,16 @@ describe('a record an older build left mid terminal handoff', () => { claimStatus: 'conflicted', handoffStage: 'manual-recovery' }) - expect(await send('while the terminal still runs')).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_conflict' } + // Accepted, then rejected by the start that cannot take the lease: the chat says why. + expect(await delivered('while the terminal still runs')).toMatchObject({ + dispatchState: 'rejected' }) + expect( + host + .journalSnapshot(SESSION) + .items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) + expect(dispatch).not.toHaveBeenCalled() expect(stopOwnerProcess).not.toHaveBeenCalled() expect(acquire).not.toHaveBeenCalled() @@ -222,7 +256,10 @@ describe('a record an older build left mid terminal handoff', () => { await host.hold(SESSION, 'surface-1') expect(stopOwnerProcess).not.toHaveBeenCalled() - expect(await send('after the terminal closed')).toMatchObject({ ok: true }) + expect(await delivered('after the terminal closed')).toMatchObject({ + dispatchState: 'pending' + }) + expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', claimStatus: 'live', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 62be89001596..24f341d28da2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -107,7 +107,8 @@ export async function admitAndRunAgentSessionMutation( envelope, hostFingerprint, now: request.now(), - ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), + ...(plan.conversationWrite ? { conversationWrite: true } : {}) }) if (!admitted) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) @@ -141,7 +142,8 @@ export async function admitAndRunAgentSessionMutation( envelope, hostFingerprint, ledger: { decision: 'admit', row: admission.row }, - lease: record.lease + lease: record.lease, + ...(plan.conversationWrite ? { conversationWrite: true } : {}) }) if (rerun.decision === 'refused') { return refuseAgentSessionMutation(rerun.refusal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index d8d2876d272e..fadd142a8faa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -28,8 +28,9 @@ export type MutationPlan = { method: string fields: Record operationIdScope?: 'global' + /** Admitted without the writer lease: see `admitAgentSessionMutation`. */ + conversationWrite?: true markUnknownBeforeRun?: boolean - beforeRun?: () => void run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean @@ -49,19 +50,22 @@ export function sendPlan(params: { return { method: 'agentSession.send', operationIdScope: 'global', + conversationWrite: true, markUnknownBeforeRun: true, // A control signal is not payload; it cannot alter durable replay. fields: { body: params.body }, - ...(params.beforeRun ? { beforeRun: params.beforeRun } : {}), recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. - run: (ctx) => - performSend(ctx, { + run: (ctx) => { + // Asked at acceptance: a send accepted after this one is queued behind it. + params.beforeRun?.() + return performSend(ctx, { clientMessageId, payloadFingerprint: params.envelope.payloadFingerprint, body: params.body - }), + }) + }, replay: (ctx, outcome) => { const submission = ctx.journal .submissions() @@ -100,6 +104,8 @@ export function cancelPlan(params: { }): MutationPlan { return { method: 'agentSession.cancel', + // Stop is a conversation write; a prompt or background-task cancel needs the live child. + ...(params.scope || params.prompt ? {} : { conversationWrite: true as const }), fields: { turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index b1eb5704f015..094ddffdcb27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -2,7 +2,6 @@ import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionPreDispatchError, - AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS, runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' import { @@ -42,6 +41,9 @@ async function context(): Promise { } } +/** Longer than any bookkeeping bound: a refusal must already be answered by then. */ +const SETTLED_WAIT_MS = 2_000 + afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() @@ -80,7 +82,7 @@ it('returns a pre-dispatch refusal without waiting on redundant uncertainty pers }) try { await refusing.promise - await vi.advanceTimersByTimeAsync(AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(SETTLED_WAIT_MS) expect(returned).toBe(true) expect(writes).toHaveBeenCalledOnce() expect(hostTestState().dispatch).not.toHaveBeenCalled() @@ -129,9 +131,10 @@ it.each([1, 2])( } ) -// The pre-dispatch check judges only what the journal already holds; the send path never waits on -// the provider's stream barrier, so a sink that stalls or fails cannot delay or double a send. -it('dispatches without touching the event-stream barrier', async () => { +// A send's plan only accepts: it records the submission and never waits on the provider's stream +// barrier, so a sink that stalls or fails cannot delay or double a send. Handing it over is the +// delivery loop's. +it('accepts without touching the event-stream barrier or the provider', async () => { const ctx = await context() const { store } = hostTestState() vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() @@ -149,42 +152,33 @@ it('dispatches without touching the event-stream barrier', async () => { }) expect(result).toMatchObject({ ok: true }) expect(beforeRun).toHaveBeenCalledOnce() - expect(hostTestState().dispatch).toHaveBeenCalledOnce() - expect(ctx.journal.submissions()[0]?.dispatchState).toBe('accepted') + expect(hostTestState().dispatch).not.toHaveBeenCalled() + expect(ctx.journal.submissions()[0]).toMatchObject({ + dispatchState: 'pending', + handoverRecorded: true + }) expect(barrier).not.toHaveBeenCalled() }) -it('refuses a superseded send without waiting on a stalled refusal write, and never dispatches late', async () => { +it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => { const ctx = await context() const { store } = hostTestState() vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() - const pending = Promise.withResolvers() - const refusing = Promise.withResolvers() - vi.spyOn(ctx.journal, 'resolveDispatch').mockImplementationOnce(() => { - refusing.resolve() - return pending.promise.then(() => ctx.journal.cursor()) - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) const beforeRun = vi.fn(() => { throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') }) const body = hostTestMessage('Continue the interrupted work') const operation = envelope('agentSession.send', { body }) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = runSettledAgentSessionMutation({ + const result = await runSettledAgentSessionMutation({ store, operationCallerKey: 'test', envelope: operation, context: ctx, plan: sendPlan({ envelope: operation, body, beforeRun }) }).catch((error: unknown) => error) - await refusing.promise - await vi.advanceTimersByTimeAsync(AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS) - expect(await result).toBeInstanceOf(AgentSessionPreDispatchError) - expect(hostTestState().dispatch).not.toHaveBeenCalled() - expect(ctx.journal.submissions()[0]?.dispatchState).toBe('pending') - pending.resolve() - await vi.advanceTimersByTimeAsync(0) + expect(result).toBeInstanceOf(AgentSessionPreDispatchError) + expect(ctx.journal.submissions()).toEqual([]) expect(hostTestState().dispatch).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index baf871aef65e..5d5009aa918c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -11,8 +11,6 @@ export class AgentSessionPreDispatchError extends Error { } } -export const AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS = 2_000 - export async function runSettledAgentSessionMutation(input: { store: AgentSessionRecordStore operationCallerKey: string @@ -33,10 +31,7 @@ export async function runSettledAgentSessionMutation(input: { if (input.plan.markUnknownBeforeRun) { await settle({ status: 'unknown' }) } - outcome = await input.plan.run({ - ...input.context, - ...(input.plan.beforeRun ? { beforeDispatch: input.plan.beforeRun } : {}) - }) + outcome = await input.plan.run(input.context) await settle( outcome.ok ? (input.plan.settledOutcome?.(outcome.value) ?? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 7957ed326a8e..853fa5784a0c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -152,6 +152,11 @@ describe('structured session options and close', () => { envelope: envelope('agentSession.send', { body }), body }) + // Accepted, then handed over by the delivery loop; the status is read once it answered. + await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) + await vi.waitFor(() => + expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') + ) const events: AgentSessionStatusEvent[] = [] host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) expect(events).toEqual([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 52699fd062f6..bb5d29d4dead 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -102,7 +102,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('starting') - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') @@ -116,7 +116,7 @@ describe('a publish-first Claude create whose init is slow', () => { ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -126,7 +126,7 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) await host.close(SESSION) const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -138,7 +138,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') 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 index 34e3fb8a4cf8..21c3d9a5f7f5 100644 --- 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 @@ -48,6 +48,11 @@ const store = { } as unknown as AgentSessionRecordStore let journalRoot: string +const openDeps = () => ({ + store, + journalRoot, + adapter: {} +}) const opened: AgentSessionJournal[] = [] async function writeRemnant(name: string): Promise { @@ -73,29 +78,29 @@ describe('a session whose journal is still the pre-SQLite format', () => { it('is published, carrying the message that explains it', async () => { const transcript = await writeRemnant('log.jsonl') - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).not.toBeNull() - opened.push(restored!.journal) - const disclosed = restored!.journal + opened.push(restored!.session.journal) + const disclosed = restored!.session.journal .snapshot() .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) expect(disclosed.join('')).toContain(transcript) // Publishing it costs no agent process; acquisition still waits for the user. - expect(restored!.hasProviderChild).toBe(false) + expect(restored!.session.hasProviderChild).toBe(false) }) it('is published for a remnant whose log is gone', async () => { await writeRemnant('snapshot.json') - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).not.toBeNull() - opened.push(restored!.journal) + opened.push(restored!.session.journal) }) it('still drops a session with neither a journal nor a remnant', async () => { - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).toBeNull() }) @@ -103,7 +108,7 @@ describe('a session whose journal is still the pre-SQLite format', () => { it('still drops a session with no record', async () => { await writeRemnant('log.jsonl') - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, 'unknown-session') + 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 index 65b913daf42a..0e1441b729c7 100644 --- 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 @@ -1,104 +1,32 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' -import { loadJournal } from '../agent-session-journal/journal-open' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { existsSync } from 'node:fs' +import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' import { - attachFingerprintFields, - journalIdentityFor, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' - -export type RestoredStructuredAgentSessionRead = { - journal: AgentSessionJournal - params: AgentSessionAttachParams - fence: number - hasProviderChild: false - providerChildPhase: 'ready' - acquisitionGeneration: null -} + openStructuredAgentSessionConversationJournal, + type OpenedStructuredAgentSessionConversation, + type StructuredAgentSessionConversationOpenDeps +} from './structured-agent-session-conversation-open' +/** + * A reader's open: the conversation's own open, for a session that has a journal to read. One + * with none — never written, or gone — stays unpublished rather than founding an empty one. + * Opening can still write: the crash boundary, and the row explaining an old-format history. + */ export async function restoreStructuredAgentSessionRead( - store: AgentSessionRecordStore, - journalRoot: string, + deps: StructuredAgentSessionConversationOpenDeps, sessionId: string -): Promise { - const record = store.getRecord(sessionId) +): Promise { + const record = deps.store.getRecord(sessionId) if (!record) { return null } - const params = attachParamsForRecord(record, { - clientOperationId: `read-restore:${record.sessionId}`, - expectedRuntimeFence: record.lease.runtimeFence - }) - const journalDir = journalDirectoryFor(journalRoot, { + const journalDir = journalDirectoryFor(deps.journalRoot, { workspaceId: record.location.workspaceId, sessionId }) - const loaded = loadJournal(journalDir, sessionId) - if (loaded?.corrupt) { + // A session still in the pre-SQLite format has no `journal.db`; the open imports it. + if (!existsSync(journalDatabaseFile(journalDir)) && !findJournalFileFormatRemnant(journalDir)) { return null } - // A session still in the pre-SQLite format has no `journal.db` to load. Dropping - // it here leaves it unpublished, which is also what prunes its tab out of the - // saved workspace — so the chat disappears with nowhere to explain itself. - if (!loaded && !findJournalFileFormatRemnant(journalDir)) { - return null - } - const journal = await openAgentSessionJournal({ - identity: journalIdentityFor(record, params), - journalDir, - // Omitted, not `null`: the store reads `null` as "replay already ran and - // found nothing" and founds a fresh epoch. In process the probe above is the - // previous statement, so the window is zero-width; this holds the line for a - // database another process creates in between. - ...(loaded ? { loaded } : {}) - }) - // Read restore opens the journal and nothing else: no adapter call, so no - // provider child. Opening it can still write — a session whose history is in - // the old format founds its epoch and commits the row explaining that here. - return { - journal, - params, - fence: record.lease.runtimeFence, - hasProviderChild: false, - providerChildPhase: 'ready', - acquisitionGeneration: null - } -} - -export function attachParamsForRecord( - record: AgentSessionRecord, - input: { - clientOperationId: string - expectedRuntimeFence: number - } -): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: record.sessionId, - clientOperationId: input.clientOperationId, - expectedRuntimeFence: input.expectedRuntimeFence, - payloadFingerprint: '' - }, - location: record.location, - provider: record.provider, - agent: record.provider, - accountHome: record.accountHome, - runtimeKind: 'native' - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: record.sessionId, - fields: attachFingerprintFields(params) - }) - } - } + 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 index 5d579fbe256e..6a105fa0c1d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -19,8 +19,11 @@ describe('StructuredAgentSessionReadableRestorer', () => { (sessionId) => ({ sessionId }) as AgentSessionRecord ) const restorer = new StructuredAgentSessionReadableRestorer({ - store: { listRecords: () => records } as never, - journalRoot: '/tmp/journals', + openDeps: { + store: { getRecord: () => null, listRecords: () => records }, + journalRoot: '/tmp/journals', + adapter: {} + }, supportsRecord: () => true, reconcile: async () => null, resolveRecovery: async () => undefined, 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 index a11c3a85b6c1..ed10f63e44ce 100644 --- 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 @@ -1,10 +1,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { RestoredStructuredAgentSessionRead } from './structured-agent-session-read-restore' +import type { StructuredAgentSessionReadRestoreDeps } from './structured-agent-session-restart-restore' import { restoreOneStructuredAgentSessionRead, - restoreOneStructuredAgentSessionReadUnderSerialize, restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' @@ -12,19 +9,8 @@ export class StructuredAgentSessionReadableRestorer { private restorePromise: Promise | null = null constructor( - private readonly input: { - store: AgentSessionRecordStore - journalRoot: string + private readonly input: StructuredAgentSessionReadRestoreDeps & { supportsRecord: (record: AgentSessionRecord) => boolean - reconcile: (sessionId: string) => Promise - resolveRecovery: (sessionId: string) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - hasSession: (sessionId: string) => boolean - onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void - retrySettlement: ( - sessionId: string, - params: RestoredStructuredAgentSessionRead['params'] - ) => Promise } ) {} @@ -55,19 +41,8 @@ export class StructuredAgentSessionReadableRestorer { return this.input.hasSession(sessionId) } - /** `restoreOne` for a caller already inside the session's serialize. Reconciliation is skipped - * on purpose: a lease this host has not adjudicated is the attach's problem, and a replay - * needs only the journal. */ - async restoreOneUnderSerialize(sessionId: string): Promise { - if (!this.supports(sessionId)) { - return false - } - await restoreOneStructuredAgentSessionReadUnderSerialize(this.input, sessionId) - return this.input.hasSession(sessionId) - } - private supports(sessionId: string): boolean { - const record = this.input.store.getRecord(sessionId) + const record = this.input.openDeps.store.getRecord(sessionId) return record !== null && this.input.supportsRecord(record) } @@ -75,7 +50,7 @@ export class StructuredAgentSessionReadableRestorer { const targetOrder = sessionIds ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) : null - const records = this.input.store + const records = this.input.openDeps.store .listRecords() .filter( (record) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 87f4f9e3d65e..0d8733898909 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -106,6 +106,7 @@ async function createHarness(options: { attached?: boolean } = {}) { async function abandonHost(host: StructuredAgentSessionHost): Promise { host['runtimeState'].stopLeaseRenewal() host['holds'].dispose() + host['conversationDelivery'].loop.dispose() await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) host['sessions'].clear() } @@ -225,16 +226,20 @@ const UNREACHABLE = new Set([ // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. 'agentSession.setOption:agent_session_identity_required', 'agentSession.send:agent_session_identity_required', - // No structured-agent-session host branch emits agent_session_journal_unreadable. + // Only a send opens the conversation it writes to. 'agentSession.setOption:agent_session_journal_unreadable', - 'agentSession.send:agent_session_journal_unreadable', // Send reconstructs doubt from its global tombstone instead of refusing it. 'agentSession.send:agent_session_operation_unknown', // Only a send restarts a lost owner. 'agentSession.setOption:agent_session_owner_restart_failed', // A write names its target, not an owner generation; only an attach compares fences. 'agentSession.setOption:agent_session_checkpoint_stale', - 'agentSession.send:agent_session_checkpoint_stale' + 'agentSession.send:agent_session_checkpoint_stale', + // A send is a conversation write: admitted whoever owns the lease, and a start it needs that + // fails rejects the accepted message rather than refusing the call. + 'agentSession.send:agent_session_conflict', + 'agentSession.send:execution_owner_reconciling', + 'agentSession.send:agent_session_owner_restart_failed' ]) describe('agentSessionRefusalOperationState host oracle', () => { @@ -251,7 +256,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { expect(stale.setOption).toHaveBeenCalledTimes(1) const conflict = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + for (const method of ['agentSession.setOption'] as const) { await setLease(conflict, (current) => ({ ...current, lease: { ...current.lease, handoffStage: 'new-owner-proving' } @@ -331,7 +336,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) const reconciling = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + for (const method of ['agentSession.setOption'] as const) { await setLease(reconciling, (current) => ({ ...current, lease: { ...current.lease, unreconciled: true } @@ -348,16 +353,21 @@ describe('agentSessionRefusalOperationState host oracle', () => { ) } - const unrecoverable = await createHarness() - await unrecoverable.host.close(SESSION) - unrecoverable.host.deps.adapter.acquire = async () => { - throw new Error('no provider thread to resume') + const unreadable = await createHarness() + await unreadable.host.close(SESSION) + unreadable.host.deps.adapter.historyFilePath = async () => { + throw new Error('transcript unreadable') } + const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } record( await assertHostAgreement( - unrecoverable, - { method: 'agentSession.send', operationId: operationId() }, - 'agent_session_owner_restart_failed' + unreadable, + unreadableSend, + 'agent_session_journal_unreadable', + async () => { + delete unreadable.host.deps.adapter.historyFilePath + return { harness: unreadable, spec: unreadableSend } + } ) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts new file mode 100644 index 000000000000..8ab9bb250ad5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -0,0 +1,63 @@ +// The restart continuation is accepted like any send, so its verdict is its delivery: a cold start +// longer than the legacy client wait must not turn a continuation that went through into an +// "unconfirmed" one. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' +import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' + +const SESSION = 'session-1' +const MESSAGE = 'continuation-1' + +let submission: AgentJournalSubmission +const journal = { + submissions: (): AgentJournalSubmission[] => [submission], + cursor: () => ({ epoch: 'epoch-1', sequence: 1 }) +} + +beforeEach(() => { + vi.useFakeTimers() + submission = { + clientMessageId: MESSAGE, + fence: 2, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true + } +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('the restart continuation waits for its delivery (W18)', () => { + it('reaches accepted after a 40 s cold start, with nothing filed as unconfirmed', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const surfaces = structuredAgentSessionRestartResumeSurfaces( + { + revealSession: async () => ({ readable: true }), + hold: async () => undefined, + release: () => undefined, + send: async () => { + throw new Error('not used') + }, + waitForSendSettlement: settlement.wait + }, + () => 0 + ) + + const verdict = surfaces.awaitSendSettlement(SESSION, MESSAGE) + await vi.advanceTimersByTimeAsync(40_000) + submission = { ...submission, dispatchState: 'accepted', providerItemId: 'item-1' } + settlement.publish(SESSION, journal) + + await expect(verdict).resolves.toMatchObject({ + value: { submission: { dispatchState: 'accepted' } } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 5dcc3ca7690c..97830212b0a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -6,7 +6,6 @@ import { AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { @@ -39,17 +38,17 @@ it('files a continuation superseded by a replayed message, lists it and says so await host.restartResume.list() await host.hold(SESSION, 'pane') const events = providerEvents(acquire) - const append = AgentSessionJournal.prototype.appendSubmission - vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementationOnce( - async function (this: AgentSessionJournal, input) { - const cursor = await append.call(this, input) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'replayed-turn', ordinal: 1 }, - hostTestMessage('A queued notification the provider replayed') - ) - return cursor - } - ) + // The replay lands after the reattach and just before the continuation is accepted, which is + // where a send asks whether its offer still stands. + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'replayed-turn', ordinal: 1 }, + hostTestMessage('A queued notification the provider replayed') + ) + await host.flushStreamedEvents(SESSION) + return send(caller, params) + }) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 1bcb27ca4a2e..1d7f0033a154 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -10,7 +10,6 @@ import { } from '../../runtime/agent-session-recovery-capsule' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { @@ -52,6 +51,8 @@ export async function interruptedRestart( previous.dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Perform the original task') await previous.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + // Accepted first, handed over after: the work in flight is a send the provider took. + await vi.waitFor(() => expect(previous.dispatch).toHaveBeenCalledOnce()) } else if (work === 'children') { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 1 }, @@ -150,15 +151,17 @@ export async function supersededRefusal(userAnswers?: 'before' | 'after') { if (!events) { throw new Error('missing resumed provider event sink') } - const append = AgentSessionJournal.prototype.appendSubmission - const writing = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') - writing.mockImplementationOnce(async function (this: AgentSessionJournal, input) { - const cursor = await append.call(this, input) + // The newer message lands after the reattach and just before the continuation is accepted, + // which is where a send asks whether its offer still stands. + const send = host.send + const writing = vi.spyOn(host, 'send') + writing.mockImplementationOnce(async (caller, params) => { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, hostTestMessage('A newer task from another client') ) - return cursor + await host.flushStreamedEvents(SESSION) + return send(caller, params) }) const admit = StructuredAgentSessionResumeAdmission.prototype.run const admitting = vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index c3301861c010..ca5ba68dee0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -178,9 +178,8 @@ it('refuses at send admission once the user has sent a newer message', async () { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } ]) expect(dispatch).not.toHaveBeenCalled() - expect(host.journalSnapshot(SESSION).submissions).toMatchObject([ - { dispatchState: 'rejected', reason: 'agent_session_restart_work_superseded' } - ]) + // Refused where it would have been accepted, so the chat records no continuation at all. + expect(host.journalSnapshot(SESSION).submissions).toEqual([]) host.release(SESSION, 'pane') expect(host.isHeld(SESSION)).toBe(false) }) @@ -249,6 +248,8 @@ it.each([false, true])( hostTestMessage('A newer task from another client'), { lifecycle: true } ) + // Journaled before the continuation's acceptance asks whether its offer still stands. + await host.flushStreamedEvents(SESSION) }) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') @@ -257,8 +258,8 @@ it.each([false, true])( { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } ]) expect(dispatch).not.toHaveBeenCalled() - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(2) - expect(host.journalSnapshot(SESSION).submissions[1]?.dispatchState).toBe('rejected') + // Refused before acceptance: only the interrupted send is in the journal. + expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) host.release(SESSION, 'pane') expect(host.isHeld(SESSION)).toBe(false) // The offer is spent, but the refusal is kept as a durable failure: a retry finds it, and the @@ -560,7 +561,7 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) - expect(dispatch).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) // list; the action's read of offers and of failures; the post-action refresh of both. expect(warning).toHaveBeenCalledTimes(5) warning.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index f46ff48ce15b..41ae0a1b1a84 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -15,6 +15,12 @@ import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +const NO_OPEN_DEPS = { + store: { getRecord: () => null, listRecords: () => [] }, + journalRoot: '/tmp/journals', + adapter: {} +} + describe('restart journal restoration', () => { beforeEach(() => restoreRead.mockReset()) @@ -22,17 +28,20 @@ describe('restart journal restoration', () => { const gate = Promise.withResolvers() let active = 0 let peak = 0 - restoreRead.mockImplementation(async (_store, _root, sessionId: string) => { + restoreRead.mockImplementation(async (_deps, sessionId: string) => { active += 1 peak = Math.max(peak, active) await gate.promise active -= 1 return { - journal: {}, - params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, - fence: 1, - hasProviderChild: false, - sessionId + session: { + journal: {}, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, + fence: 1, + hasProviderChild: false, + sessionId + }, + reset: null } }) const records = Array.from( @@ -41,8 +50,7 @@ describe('restart journal restoration', () => { ) const restoration = restoreStructuredAgentSessionsOnRestart({ - store: {} as never, - journalRoot: '/tmp/journals', + openDeps: NO_OPEN_DEPS, records, reconcile: async () => null, resolveRecovery: async () => undefined, @@ -82,17 +90,19 @@ describe('restart journal restoration', () => { runtimeKind: 'native' } restoreRead.mockResolvedValue({ - journal: {}, - params, - fence: 4, - hasProviderChild: false, - acquisitionGeneration: null + session: { + journal: {}, + params, + fence: 4, + hasProviderChild: false, + acquisitionGeneration: null + }, + reset: null }) await restoreOneStructuredAgentSessionRead( { - store: {} as never, - journalRoot: '/tmp/journals', + openDeps: NO_OPEN_DEPS, reconcile: async () => null, resolveRecovery: async () => { calls.push('resolveRecovery') @@ -118,17 +128,19 @@ describe('restart journal restoration', () => { it('does not rerun settlement retry when a second restore finds the session already open', async () => { const retrySettlement = vi.fn(async () => true) restoreRead.mockResolvedValue({ - journal: {}, - params: {}, - fence: 4, - hasProviderChild: false, - acquisitionGeneration: null + session: { + journal: {}, + params: {}, + fence: 4, + hasProviderChild: false, + acquisitionGeneration: null + }, + reset: null }) await restoreOneStructuredAgentSessionRead( { - store: {} as never, - journalRoot: '/tmp/journals', + openDeps: NO_OPEN_DEPS, reconcile: async () => null, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 38c04d2a5973..719c60f3e196 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -13,27 +13,30 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' -import { - restoreStructuredAgentSessionRead, - type RestoredStructuredAgentSessionRead -} from './structured-agent-session-read-restore' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + OpenedStructuredAgentSessionConversation, + StructuredAgentSessionConversationOpenDeps +} from './structured-agent-session-conversation-open' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' const JOURNAL_RESTORE_CONCURRENCY = 4 export type StructuredAgentSessionReadRestoreDeps = { - store: AgentSessionRecordStore - journalRoot: string + openDeps: StructuredAgentSessionConversationOpenDeps & { + store: Pick + } reconcile: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean - onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void - retrySettlement: ( + onReadable: ( sessionId: string, - params: RestoredStructuredAgentSessionRead['params'] - ) => Promise + opened: OpenedStructuredAgentSessionConversation + ) => Promise | void + retrySettlement: (sessionId: string, params: AgentSessionAttachParams) => Promise } /** @@ -62,7 +65,7 @@ export async function restoreOneStructuredAgentSessionRead( export async function restoreOneStructuredAgentSessionReadUnderSerialize( input: Pick< StructuredAgentSessionReadRestoreDeps, - 'store' | 'journalRoot' | 'hasSession' | 'onReadable' | 'retrySettlement' + 'openDeps' | 'hasSession' | 'onReadable' | 'retrySettlement' >, sessionId: string ): Promise { @@ -70,16 +73,12 @@ export async function restoreOneStructuredAgentSessionReadUnderSerialize( // A surface that took a hold mid-restore already attached this one. return } - const restored = await restoreStructuredAgentSessionRead( - input.store, - input.journalRoot, - sessionId - ) - if (!restored) { + const opened = await restoreStructuredAgentSessionRead(input.openDeps, sessionId) + if (!opened) { return } - input.onReadable(sessionId, restored) - await input.retrySettlement(sessionId, restored.params) + await input.onReadable(sessionId, opened) + await input.retrySettlement(sessionId, opened.session.params) } export async function restoreStructuredAgentSessionsOnRestart( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 07ec6b81599f..66461a0c8739 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -11,7 +11,9 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-host' +import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' /** The caller key the continuation sends under, so its writes are attributable to Orca itself. */ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = @@ -34,7 +36,8 @@ type RestartResumeHostBindings = { /** The host's existing settlement waiter; a send returns while its dispatch is still pending. */ waitForSendSettlement: ( sessionId: string, - clientMessageId: string + clientMessageId: string, + options: SendSettlementWaitOptions ) => Promise<{ value: AgentSessionSendResult } | undefined> } @@ -48,7 +51,10 @@ export function structuredAgentSessionRestartResumeSurfaces( release: host.release, send: (params) => host.send({ callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, params), - awaitSendSettlement: host.waitForSendSettlement, + // Accepted like any send, so its verdict is its delivery, however long the start takes; the + // wait ends when the submission settles or the session closes. + awaitSendSettlement: (sessionId, clientMessageId) => + host.waitForSendSettlement(sessionId, clientMessageId, { budgetMs: MAX_TIMER_DELAY_MS }), onNoteFailed: () => console.warn('[structured-agent-session] restart continuation attribution failed'), now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts index 1b0540183710..5f799e4498ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts @@ -10,7 +10,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { randomUUID } from 'node:crypto' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { attachParamsForRecord } from './structured-agent-session-read-restore' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' export function isResumableStructuredAgentSessionRecord(record: AgentSessionRecord): boolean { return ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index 291a6e0c53eb..c8f75823708c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -46,23 +46,32 @@ function harness( return task() } const restorer = new StructuredAgentSessionReadableRestorer({ - store: { - getRecord: (sessionId: string) => records.find((r) => r.sessionId === sessionId) ?? null, - listRecords: () => records - } as never, - journalRoot: '/journals', + openDeps: { + store: { + getRecord: (sessionId: string) => records.find((r) => r.sessionId === sessionId) ?? null, + listRecords: () => records + }, + journalRoot: '/journals', + adapter: {} + }, supportsRecord: options.supports ?? (() => true), reconcile: async () => null, resolveRecovery: async () => undefined, serialize, hasSession: (sessionId) => live.has(sessionId), - onReadable: (sessionId, restored) => live.set(sessionId, restored), + onReadable: (sessionId, restored) => { + live.set(sessionId, restored) + }, retrySettlement: async () => true }) return { restorer, live, serializedIds } } -const readable = { journal: {}, params: {}, fence: 1 } as never +const readable = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restorer only indexes the session it is handed; no member of it is read here. + session: { journal: {}, params: {}, fence: 1 } as never, + reset: null +} afterEach(() => { vi.restoreAllMocks() 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 bc36df76df8a..318f2192cc28 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 @@ -61,17 +61,14 @@ export function createStructuredAgentSessionHostRestore( now: () => number, wiring: Omit< ConstructorParameters[0], - 'store' | 'journalRoot' | 'supportsRecord' | 'retrySettlement' + 'openDeps' | 'supportsRecord' | 'retrySettlement' > ): { restoreReadableSessions: (sessionIds?: readonly string[]) => Promise revealSession: (sessionId: string) => Promise - /** One session, for a caller already inside its serialize. */ - restoreReadableUnderSerialize: (sessionId: string) => Promise } { const restorer = new StructuredAgentSessionReadableRestorer({ - store: deps.store, - journalRoot: deps.journalRoot, + openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), retrySettlement: (sessionId, params) => retryPendingStructuredAgentSessionSettlement({ deps, sessions, sessionId, params, now }), @@ -83,7 +80,6 @@ export function createStructuredAgentSessionHostRestore( revealSession: (sessionId) => revealStructuredAgentSession(deps, sessionId, wiring.hasSession, (id) => restorer.restoreOne(id) - ), - restoreReadableUnderSerialize: (sessionId) => restorer.restoreOneUnderSerialize(sessionId) + ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index b82248cb06ff..32b05b080774 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -139,6 +139,14 @@ async function seed(acceptedSubmissions = false) { } }) ).toMatchObject({ ok: true }) + // Accepted into the conversation first; the delivery loop hands it over after. + await vi.waitFor(() => + expect( + host + .journalSnapshot(HOST_TEST_SESSION) + .submissions.find((entry) => entry.clientMessageId === clientOperationId)?.dispatchState + ).toBe('accepted') + ) if (i === 1) { selectedItemId = agentJournalSubmissionKey(clientOperationId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 0f75c9a33224..2494535e019b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -120,11 +120,16 @@ describe('structured send idempotency', () => { await performSend(context, input) const replay = await performSend(context, input) + // Acceptance records the one submission; the reused id answers with it and writes nothing. + // Handing it over is the delivery loop's, never a second accept's. expect(replay).toMatchObject({ ok: true, - value: { clientMessageId: 'shared-send-id', submission: { dispatchState: 'accepted' } } + value: { + clientMessageId: 'shared-send-id', + submission: { dispatchState: 'pending', handoverRecorded: true } + } }) - expect(dispatch).toHaveBeenCalledOnce() + expect(dispatch).not.toHaveBeenCalled() expect(journal.submissions()).toHaveLength(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 563cb6609976..90a30415c14e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -1,11 +1,11 @@ -// A send, or a hold, that finds the session's provider child gone, against the real host. +// A send, or a hold, that finds the session's provider child gone, against the real host. The +// send is accepted at once; its delivery restarts the child, or rejects it with the reason. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -24,6 +24,11 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } + +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => void): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} // Long enough that no release fires mid-test; whether one is pending is asserted directly. const GRACE_MS = 60_000 @@ -105,15 +110,39 @@ function sendParams(text: string, operationId = hostTestOperationId()) { return { envelope, body } } -/** Every status row the chat shows, oldest first; none when the session is not even readable. */ -function journalStatuses(): string[] { - if (!host.hasSession(SESSION)) { - return [] - } - const history = host.history({ sessionId: SESSION, direction: 'tail' }) - return history.ok - ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - : [] +/** Accepted at once, before any owner exists for it; answers the message id. */ +async function accept(params: ReturnType): Promise { + const result = await host.send(CALLER, params) + expect(result, JSON.stringify(result)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + return params.envelope.clientOperationId +} + +function submission(clientMessageId: string) { + return host + .journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + +/** The submission once delivery is done with it: handed over, or rejected unwritten. */ +async function settled(clientMessageId: string) { + await eventually(() => { + const current = submission(clientMessageId) + expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) + }) + return submission(clientMessageId) +} + +/** The failure rows a start the chat needed left, oldest first. */ +function errorStatuses(): string[] { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) } /** The child timed out or exited: its lease is handed back and the host holds no session. */ @@ -130,15 +159,14 @@ describe('a send with no live owner', () => { it('restarts the owner once and delivers against it', async () => { await loseOwner() - const result = await host.send(CALLER, sendParams('after the child died')) + await accept(sendParams('after the child died')) - expect(result).toMatchObject({ ok: true, replayed: false }) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) - it('restarts the owner before the send is admitted, so the send is admitted once', async () => { + it('accepts the send before anything restarts, and the restart hands it over', async () => { await loseOwner() const order: string[] = [] const spawnChild = acquire.getMockImplementation()! @@ -152,21 +180,18 @@ describe('a send with no live owner', () => { return admit(args) }) - await expect(host.send(CALLER, sendParams('ensure first'))).resolves.toMatchObject({ - ok: true, - replayed: false - }) + await accept(sendParams('accept first')) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['acquire', 'admit']) + expect(order).toEqual(['admit', 'acquire']) }) it('leaves a live owner alone', async () => { acquire.mockClear() - await expect(host.send(CALLER, sendParams('owner is live'))).resolves.toMatchObject({ - ok: true - }) + await accept(sendParams('owner is live')) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).not.toHaveBeenCalled() }) @@ -184,36 +209,35 @@ describe('a send with no live owner', () => { } })) - await host.send(CALLER, sendParams('into a cleared chat')) + await expect(host.send(CALLER, sendParams('into a cleared chat'))).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) expect(acquire).not.toHaveBeenCalled() }) it('renews the idle window on journal activity in an unheld session', async () => { await loseOwner() - await expect(host.send(CALLER, sendParams('restart'))).resolves.toMatchObject({ ok: true }) + await accept(sendParams('restart')) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) const arm = vi.spyOn(host['holds']['clock'], 'arm') - await expect(host.send(CALLER, sendParams('more activity'))).resolves.toMatchObject({ - ok: true - }) + await accept(sendParams('more activity')) - expect(arm).toHaveBeenCalledWith(SESSION) + await eventually(() => expect(arm).toHaveBeenCalledWith(SESSION)) }) it('releases the restarted child on the usual clock only when no surface holds it', async () => { await loseOwner() - await expect(host.send(CALLER, sendParams('nobody is watching'))).resolves.toMatchObject({ - ok: true - }) - expect(host['holds'].isReleasePending(SESSION)).toBe(true) + await accept(sendParams('nobody is watching')) + await eventually(() => expect(host['holds'].isReleasePending(SESSION)).toBe(true)) await host.close(SESSION) // A reading surface that does not itself restart the agent. await host.hold(SESSION, 'desktop-chat:1', { resume: false }) - await expect(host.send(CALLER, sendParams('the chat is open'))).resolves.toMatchObject({ - ok: true - }) + await accept(sendParams('the chat is open')) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) expect(host['holds'].isReleasePending(SESSION)).toBe(false) }) @@ -231,15 +255,15 @@ describe('a send with no live owner', () => { expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() - await expect(host.send(CALLER, sendParams('after an exit'))).resolves.toMatchObject({ - ok: true - }) + await accept(sendParams('after an exit')) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() }) it('restarts nothing for a resend the journal already answers', async () => { const params = sendParams('sent once') - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + await accept(params) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) // The child died during startup: the lease is handed back, the fence moves, and the session // stays readable. The client resends against the new fence. await host.handleAdapterEvent({ @@ -268,12 +292,9 @@ describe('a send with no live owner', () => { expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') // Retry rotates the id: a genuinely new send restarts the owner once. - await expect(host.send(CALLER, sendParams('sent once'))).resolves.toMatchObject({ - ok: true, - replayed: false - }) + await accept(sendParams('sent once')) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledTimes(2) }) it('restarts nothing for a send the ledger holds but the journal never saw', async () => { @@ -320,7 +341,7 @@ describe('a send with no live owner', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('admits a send that arrives after the restart has already claimed the lease', async () => { + it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { await loseOwner() const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 let claimed = () => {} @@ -334,18 +355,22 @@ describe('a send with no live owner', () => { return spawnChild!(input) }) - const first = host.send(CALLER, sendParams('first')) + const first = await accept(sendParams('first')) await claim expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(lostFence + 1) + // Written against the lost owner's fence, which admits it: a send is a conversation write. const late = sendParams('second') late.envelope.expectedRuntimeFence = lostFence const second = host.send(CALLER, late) release() - expect(await first).toMatchObject({ ok: true }) expect(await second).toMatchObject({ ok: true }) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledTimes(2) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ + first, + late.envelope.clientOperationId + ]) }) it('shares one restart between concurrent sends', async () => { @@ -358,8 +383,8 @@ describe('a send with no live owner', () => { ]) expect(results.map((result) => result.ok)).toEqual([true, true, true]) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(3)) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledTimes(3) }) it('shares one restart between a hold and a send that arrive in the same gap', async () => { @@ -372,21 +397,22 @@ describe('a send with no live owner', () => { expect(held).toMatchObject({ status: 'fulfilled' }) expect(sent).toMatchObject({ status: 'fulfilled', value: { ok: true } }) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() expect(host['holds'].isHeld(SESSION)).toBe(true) expect(host['holds'].isReleasePending(SESSION)).toBe(false) }) it('replays into a closed session without spawning anything', async () => { const params = sendParams('sent once') - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + await accept(params) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) await loseOwner() await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - // The journal was made readable for the answer; the record's lease was left as it was. + // The conversation was opened for the answer; the record's lease was left as it was. expect(host.hasSession(SESSION)).toBe(true) expect(acquire).not.toHaveBeenCalled() expect(dispatch).toHaveBeenCalledOnce() @@ -394,104 +420,83 @@ describe('a send with no live owner', () => { expect(host['holds'].isReleasePending(SESSION)).toBe(false) }) - it("refuses with the restart's own cause, in the answer and in the chat", async () => { + it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') + const cause = + 'The provider stopped before it finished starting: Not signed in. Run codex login.' - const result = await host.send(CALLER, params) + const id = await accept(params) - expect(result).toEqual({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: "Codex couldn't restart: Not signed in. Run codex login.", - // The failed attach proved its child gone: nothing runs for this session. - ownerVerdict: 'exited' - } - }) + expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) expect(dispatch).not.toHaveBeenCalled() - expect(hostErrors).not.toEqual([]) - expect(agentSessionRefusalOperationState('agent_session_owner_restart_failed')).toBe( - 'settled-rejected' - ) - // Refused before admission: the ledger holds nothing a resend would replay. - expect(store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)).toBeNull() - // The same status row a failed start leaves, so the reason outlives the error strip. - expect(journalStatuses()).toEqual([ - 'The provider stopped before it finished starting: Not signed in. Run codex login.' - ]) + // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. + expect( + store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + // One row, in the error tone, so the reason outlives the error strip. + expect(errorStatuses()).toEqual([cause]) }) - it('restarts again for a Retry of the refused send, under its own id or a new one', async () => { + it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in')) const params = sendParams('while signed out') - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } - }) + await settled(await accept(params)) + expect(acquire).toHaveBeenCalledTimes(1) - // A client that resends the same id gets another attempt, and the chat no second row. + // A client that resends the same id gets the recorded rejection, and the chat no second row. await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } + ok: true, + replayed: true, + value: { submission: { dispatchState: 'rejected' } } }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(journalStatuses()).toHaveLength(1) + expect(acquire).toHaveBeenCalledTimes(1) + expect(errorStatuses()).toHaveLength(1) - // The outbox's Retry rotates the id: also a fresh attempt. - await expect(host.send(CALLER, sendParams('while signed out'))).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } + // The outbox's Retry rotates the id: a fresh attempt, with its own row. + expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ + dispatchState: 'rejected' }) - expect(acquire).toHaveBeenCalledTimes(3) + expect(acquire).toHaveBeenCalledTimes(2) + expect(errorStatuses()).toHaveLength(2) expect(dispatch).not.toHaveBeenCalled() }) it('restarts and delivers a later send once the cause clears', async () => { await loseOwner() acquire.mockRejectedValueOnce(new Error('Not signed in')) - await expect(host.send(CALLER, sendParams('while signed out'))).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } + expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ + dispatchState: 'rejected' }) // The user signed in; nothing about the failed attempt is remembered. - await expect(host.send(CALLER, sendParams('signed in now'))).resolves.toMatchObject({ - ok: true, - replayed: false - }) + await accept(sendParams('signed in now')) + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) it('suggests a new chat only when this host has nothing to restart the chat from', async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in')) - const failed = await host.send(CALLER, sendParams('restart fails')) - expect(failed).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } - }) - expect(failed.ok ? '' : failed.refusal.message).not.toMatch(/new chat/) + const failed = await settled(await accept(sendParams('restart fails'))) + expect(failed).toMatchObject({ dispatchState: 'rejected' }) + expect(failed?.reason).not.toMatch(/new chat/) // The adapter cannot run this record where it lives: no retry would bring it back. host.deps.adapter.supportsLocation = () => false - const unresumable = await host.send(CALLER, sendParams('cannot resume here')) + const unresumable = await settled(await accept(sendParams('cannot resume here'))) expect(unresumable).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: - "Codex couldn't restart: This execution host cannot resume the requested structured agent session. Start a new chat to continue." - } + dispatchState: 'rejected', + reason: + "Codex couldn't restart: This execution host cannot resume the requested structured agent session. Start a new chat to continue." }) }) - it('runs the send as the lease stands when the restart met a lease someone else is settling', async () => { + it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { await loseOwner() vi.spyOn(host['holds'], 'ensureProviderChild').mockResolvedValueOnce({ ok: false, @@ -501,33 +506,30 @@ describe('a send with no live owner', () => { } }) - const result = await host.send(CALLER, sendParams('owner being settled')) + const id = await accept(sendParams('owner being settled')) - // The ordinary lease check answers, retryably; nothing terminal and nothing in the chat. - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) + const cause = "Codex couldn't restart: Another runtime is still adjudicating this lease." + expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) expect(acquire).not.toHaveBeenCalled() - expect(journalStatuses()).toEqual([]) + expect(errorStatuses()).toEqual([cause]) }) - it('runs the send as the lease stands when the restart itself faults', async () => { + it('rejects the message, and reports the fault, when the restart itself faults', async () => { await loseOwner() vi.spyOn(host['holds'], 'ensureProviderChild').mockRejectedValueOnce( new Error('spawn-token mint failed') ) - const result = await host.send(CALLER, sendParams('bookkeeping failed')) + const id = await accept(sendParams('bookkeeping failed')) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart: spawn-token mint failed." }) expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) ) - expect(journalStatuses()).toEqual([]) + expect(errorStatuses()).toHaveLength(1) }) it('keeps a second surface holder taken during an auto-restart, and starts nothing for it', async () => { @@ -663,20 +665,22 @@ describe('a send with no live owner', () => { }) }) - it('leaves a lease it cannot adjudicate alone', async () => { + it('adjudicates a lease this host has not reconciled before its delivery resumes it', async () => { await loseOwner() await store.transitionHandoff(SESSION, (current) => ({ ...current, lease: { ...current.lease, unreconciled: true } })) - const result = await host.send(CALLER, sendParams('owner unverifiable')) + // The send's start is the same serialized resume a hold runs, reconciliation first. + await accept(sendParams('owner unverified')) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'live' }) - expect(acquire).not.toHaveBeenCalled() }) }) @@ -691,18 +695,15 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { const params = sendParams('after the release') params.envelope.expectedRuntimeFence = seenFence - expect(await host.send(CALLER, params)).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'accepted' } } - }) + const id = await accept(params) + expect(await settled(id)).toMatchObject({ dispatchState: 'accepted' }) expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(fence).toBeGreaterThan(seenFence + 1) // The pane learns the fence the host is at, not the one it subscribed under. - expect(frames.at(-1)).toMatchObject({ fence }) + await eventually(() => expect(frames.at(-1)).toMatchObject({ fence })) }) it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { @@ -718,7 +719,8 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { return spawnChild(input) }) - const first = host.send(CALLER, sendParams('starts the agent')) + const firstParams = sendParams('starts the agent') + const first = host.send(CALLER, firstParams) await claim const cancelFields = { turnId: 'turn-1' } const stop = host.cancel(CALLER, { @@ -742,7 +744,14 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await first).toMatchObject({ ok: true }) expect(await stop).toMatchObject({ ok: true, replayed: false }) expect(await second).toMatchObject({ ok: true, replayed: false }) + // The Stop withdrew the message its start held; the one typed after it is delivered. + expect(await settled(late.envelope.clientOperationId)).toMatchObject({ + dispatchState: 'accepted' + }) + expect(submission(firstParams.envelope.clientOperationId)).toMatchObject({ + dispatchState: 'rejected' + }) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 09986f2e1296..1fa923e971f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -1,23 +1,8 @@ -// What a send needs from the session before its lease is checked. -// -// A provider child that exits or fails to start hands its lease back. Before this, a send to that -// session was refused `agent_session_ownership_unknown` — which a client reads as "not admitted -// yet" and resends forever — and only a surface hold could ever make a new child. Now the send -// makes sure it has an owner as a step of its own serialized admission: a released lease where -// resume is allowed gets a child first; anything else runs as it is and meets the lease check. -// A restart that fails refuses with a code the client stops auto-retrying on, carrying the -// restart's own cause, and writes that cause into the chat the way a start that failed does, so -// the user sees why. A manual Retry or a new send is a fresh attempt: a refusal before admission -// leaves no ledger row behind. -// -// The ledger's answer comes first, so a send it already holds a row for restarts nothing: -// admission replays or refuses it whoever owns the session now, and a closed session is made -// readable for that, never given a child. Otherwise each resend of a message whose child died at -// startup would spawn another child that dies the same way. -// -// A child that has not proven its start is still the owner: the send is admitted against it and -// the adapter holds the message until startup lands, or rejects it with the child's own reason -// when the child dies first. The exit settlement writes that reason into the chat. +// What a send or a Stop needs from the session before the ledger places its row: the +// conversation open. Nothing here needs an owner — a send is accepted into the conversation and +// the delivery loop makes the session ready — so a refusal before acceptance is only one the +// conversation itself makes: a rewind or conversation command in doubt, a cleared conversation, +// or a journal that cannot be opened. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -25,45 +10,39 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' -import { boundJournalStatusText } from '../agent-session-journal/journal-prompt-body-bounds' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import { ownerRestartFailedOutcome, providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import type { AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' -import { isResumableStructuredAgentSessionRecord } from './structured-agent-session-resume-eligibility' +import { + AGENT_SESSION_NOT_ATTACHED, + type AgentSessionMutationSessionPreparation +} from './structured-agent-session-mutation-admission' import { rewindRefusal } from './structured-rewind-refusal' /** - * What a refused resume means for the send that ran it. `transient`: the resume met a lease - * someone else is settling, which is not proof it cannot resume — the send runs as the lease - * stands and admission reports it. `failed`: the restart itself failed; the send answers with the - * cause and stops the client's retry loop, and the user may clear the cause and retry. - * `unresumable`: this host has nothing to restart the chat from — no record, or none it can run — - * so only a new chat continues. A new wire code does not compile until it is classified here. + * Whether a refused start leaves the chat anything to start again from. `unresumable`: this host + * has nothing to restart it from — no record, or none it can run — so only a new chat continues. + * A new wire code does not compile until it is classified here. */ -const RESUME_REFUSAL_OUTCOME: Record< - AgentSessionWireRefusalCode, - 'transient' | 'failed' | 'unresumable' -> = { - execution_owner_reconciling: 'transient', - agent_session_conflict: 'transient', - agent_session_checkpoint_stale: 'transient', - agent_session_ownership_unknown: 'transient', - agent_session_operation_capacity: 'transient', - structured_agent_session_unsupported: 'unresumable', - agent_session_operation_conflict: 'failed', - agent_session_operation_expired: 'failed', - agent_session_operation_invalid: 'failed', - agent_session_operation_unknown: 'failed', - agent_session_item_revision_stale: 'failed', - agent_session_already_resolved: 'failed', - agent_session_identity_required: 'unresumable', - agent_session_journal_unreadable: 'failed', - agent_session_owner_restart_failed: 'failed' +const START_REFUSAL_RESUMABLE: Record = { + execution_owner_reconciling: true, + agent_session_conflict: true, + agent_session_checkpoint_stale: true, + agent_session_ownership_unknown: true, + agent_session_operation_capacity: true, + structured_agent_session_unsupported: false, + agent_session_operation_conflict: true, + agent_session_operation_expired: true, + agent_session_operation_invalid: true, + agent_session_operation_unknown: true, + agent_session_item_revision_stale: true, + agent_session_already_resolved: true, + agent_session_identity_required: false, + agent_session_journal_unreadable: true, + agent_session_owner_restart_failed: true } /** Why the record refuses any send right now, whoever owns it; null when a send may run. */ @@ -93,133 +72,41 @@ export function structuredAgentSessionSendBlock( return null } -/** Whether this send is the one that must bring the owner back: no child, a lease handed back - * cleanly, and nothing on the record that refuses the send anyway. Live, unverifiable, still - * reserved, or handed off: that lease is not this send's to replace. */ -export function structuredAgentSessionSendNeedsOwner( - session: StructuredAgentSessionHostSession | undefined, - record: AgentSessionRecord -): boolean { - return ( - session?.hasProviderChild !== true && - isResumableStructuredAgentSessionRecord(record) && - structuredAgentSessionSendBlock(record) === null - ) -} - -type SendPreparationContext = Pick< - StructuredAgentSessionMutationContext, - 'deps' | 'sessions' | 'holds' | 'restoreReadable' -> - -export async function prepareStructuredAgentSessionSend( - context: SendPreparationContext, - envelope: AgentSessionMutationEnvelope, - ledger: 'admit' | 'replay', - record: AgentSessionRecord +/** The conversation a send or a Stop writes to, opened when this host holds it closed. */ +export async function openConversationForWrite( + openConversation: (sessionId: string) => Promise, + envelope: AgentSessionMutationEnvelope ): Promise { - const { sessionId } = record - if (ledger !== 'admit') { - if (!context.sessions.has(sessionId)) { - await context.restoreReadable(sessionId) + try { + if (await openConversation(envelope.sessionId)) { + return { ok: true } } - return { ok: true } - } - if (structuredAgentSessionSendNeedsOwner(context.sessions.get(sessionId), record)) { - const refusal = await restartOwnerForSend(context, envelope, record) - if (refusal) { - return { ok: false, refusal } + return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } + } catch (error) { + return { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: `The conversation could not be opened: ${ + error instanceof Error ? error.message : String(error) + }` + } } } - return { ok: true } } -/** One restart attempt. Answers with the refusal that ends the send, or null when the send goes - * on to admission — after a child, after a transient refusal, or after a fault in the restart's - * own bookkeeping, which is reported and never gates the user's action. */ -async function restartOwnerForSend( - context: SendPreparationContext, - envelope: AgentSessionMutationEnvelope, - record: AgentSessionRecord -): Promise { - const { sessionId } = envelope - let resumed: Awaited> - try { - resumed = await context.holds.ensureProviderChild(sessionId) - } catch (error) { - context.deps.onEventSinkError?.({ sessionId, error }) - return null - } - const outcome = resumed.ok ? null : RESUME_REFUSAL_OUTCOME[resumed.refusal.code] - if (resumed.ok || outcome === 'transient') { - return null +/** What the chat says, in its row and on every message it rejects, when the delivery loop could + * not make the session ready. A child that died starting reads as any start that died does. */ +export function structuredAgentSessionStartFailureText( + record: AgentSessionRecord | null, + cause: AgentSessionWireRefusal +): string { + if (cause.ownerVerdict === 'exited') { + return providerStartupFailureOutcome(cause.message) } - const refusal = ownerRestartFailedRefusal(record, resumed.refusal, outcome !== 'unresumable') - context.deps.onEventSinkError?.({ - sessionId, - error: new Error(`${resumed.refusal.code}: ${resumed.refusal.message}`) + return ownerRestartFailedOutcome({ + agentName: record ? TUI_AGENT_DISPLAY_NAMES[record.provider] : 'The agent', + reason: cause.message, + resumable: START_REFUSAL_RESUMABLE[cause.code] }) - // A restart whose child died starting leaves the row any start that died leaves, so the chat - // reads the same whether the send met that death before admission or after it. - await recordFailedRestart( - context, - envelope, - resumed.refusal.ownerVerdict === 'exited' - ? providerStartupFailureOutcome(resumed.refusal.message) - : refusal.message - ) - return refusal -} - -/** The client stops on the code; the message carries the restart's own cause, and the verdict — - * when the failed attach proved its child gone — tells a client nothing runs for the session. */ -function ownerRestartFailedRefusal( - record: AgentSessionRecord, - cause: AgentSessionWireRefusal, - resumable: boolean -): AgentSessionWireRefusal { - return { - code: 'agent_session_owner_restart_failed', - message: ownerRestartFailedOutcome({ - agentName: TUI_AGENT_DISPLAY_NAMES[record.provider], - reason: cause.message, - resumable - }), - ...(cause.ownerVerdict ? { ownerVerdict: cause.ownerVerdict } : {}) - } -} - -/** The same status row a start that failed leaves in the chat, so the reason outlives the error - * strip. The journal is made readable for it when the failed attach left none behind. Keyed by - * the send, not the clock: a resend of the same id that fails again adds no second row. */ -async function recordFailedRestart( - context: SendPreparationContext, - envelope: AgentSessionMutationEnvelope, - text: string -): Promise { - const { sessionId } = envelope - try { - if (!context.sessions.has(sessionId)) { - await context.restoreReadable(sessionId) - } - const session = context.sessions.get(sessionId) - if (!session) { - return - } - const settlementId = `failed-restart:${envelope.clientOperationId}` - await session.journal.appendLifecycleBatch({ - settlementId, - fence: session.fence, - recovered: true, - mutations: [ - { - kind: 'item', - identity: { provider: 'orca', clientMessageId: settlementId }, - body: { kind: 'status', text: boundJournalStatusText(text) } - } - ] - }) - } catch (error) { - context.deps.onEventSinkError?.({ sessionId, error }) - } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index e2e2fb81102e..211d8684e151 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -1,12 +1,11 @@ // A session that published and then lost its child before startup (not signed in, say) keeps a // released lease and a chat the user can still type into. The send is the user asking for the -// child back: the host restarts it before admitting the write and delivers against the new owner, -// instead of parking the message behind a lease nothing would ever re-acquire. +// child back: the host accepts the message, and its delivery restarts the child and hands the +// message to the new owner, instead of parking it behind a lease nothing would ever re-acquire. // // A child is published before it has proven its start, and it owns the send from that moment: the -// message is admitted against it and the adapter holds it for the start. When the child exits -// first, the exit settlement rejects the message and writes the cause into the chat, once, and -// the next send is a fresh restart. +// message is handed to it. When the child exits first, the exit settlement rejects the message and +// writes the cause into the chat, once, and the next send is a fresh restart. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -28,6 +27,11 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } + +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => void): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' let root: string @@ -53,7 +57,7 @@ function sendEnvelope( } } -/** A send is admitted against the child it meets, proven or not; the adapter holds the rest. */ +/** A send is accepted at once and handed to the child delivery finds or starts. */ async function send( text: string, fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -63,9 +67,11 @@ async function send( expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true, replayed: false, - value: { submission: { dispatchState: 'pending' } } + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } }) - return sent.ok ? sent.value.clientMessageId : '' + const clientMessageId = sent.ok ? sent.value.clientMessageId : '' + await eventually(() => expect(submission(clientMessageId)?.handedOverAt).toBeDefined()) + return clientMessageId } /** The child of the current acquisition, as the adapter would identify it in a lifecycle event. */ @@ -166,8 +172,7 @@ describe('a send into a published session whose child ended before startup', () await send('hello again', releasedFence) - // A send still fenced to the lost owner is admitted once against the restarted one, with no - // stale round trip. + // Accepted at the lost owner's fence and handed to the child delivery started, once. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).toHaveBeenCalledOnce() @@ -207,7 +212,7 @@ describe('a send into a published session whose child ended before startup', () // One spawn per user action: nothing restarted it a second time. expect(acquire).toHaveBeenCalledTimes(2) - // Retry is a fresh action: it restarts once and is admitted against the new child. + // Retry is a fresh action: it restarts once and is handed to the new child. await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledTimes(2) @@ -216,7 +221,7 @@ describe('a send into a published session whose child ended before startup', () }) describe('a send while the child of the first start is still proving itself', () => { - it('is admitted against the starting child, and nothing restarts it', async () => { + it('is handed to the starting child, and nothing restarts it', async () => { await send('hello') expect(dispatch).toHaveBeenCalledOnce() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts index b461d508f42a..ff9765d1cee7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -61,7 +61,7 @@ describe('structured send settlement compatibility wait', () => { it('removes an abandoned wait on transport cancellation', async () => { const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) const controller = new AbortController() - const pending = settlements.wait('session-1', 'client-1', controller.signal) + const pending = settlements.wait('session-1', 'client-1', { signal: controller.signal }) controller.abort(new Error('transport closed')) await expect(pending).rejects.toThrow('transport closed') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index 6150e3412a69..bc265833c429 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -3,8 +3,12 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +/** What a settlement read needs of a journal. */ +type SendSettlementJournal = Pick + type SettledSend = { cursor: AgentJournalCursor value: AgentSessionSendResult @@ -12,8 +16,19 @@ type SettledSend = { type SendSettlement = SettledSend | 'pending' | 'missing' +/** What ends a wait: the provider's answer, or only the host handing the message over. */ +export type SendSettlementPoint = 'answered' | 'handed-over' + +export type SendSettlementWaitOptions = { + signal?: AbortSignal + /** How long to observe; unanswered by then resolves undefined. */ + budgetMs?: number + until?: SendSettlementPoint +} + type SendSettlementWaiter = { clientMessageId: string + until: SendSettlementPoint resolve: (result: SettledSend | undefined) => void reject: (error: Error) => void timer: ReturnType @@ -23,12 +38,15 @@ type SendSettlementWaiter = { // Known legacy clients abandon the RPC after 15s without cancelling its socket dispatch. const SEND_SETTLEMENT_WAIT_TIMEOUT_MS = 30_000 +/** Long enough for a cold provider start; a longer one replays through the same wait. */ +export const STRUCTURED_AGENT_SESSION_START_WAIT_MS = 120_000 const MAX_SEND_SETTLEMENT_WAITERS_PER_SESSION = 64 const MAX_SEND_SETTLEMENT_WAITERS = 1_024 function settledSend( - journal: AgentSessionJournal, + journal: SendSettlementJournal, clientMessageId: string, + until: SendSettlementPoint, submission: AgentJournalSubmission | undefined = journal .submissions() .find((candidate) => candidate.clientMessageId === clientMessageId) @@ -36,9 +54,11 @@ function settledSend( if (!submission) { return 'missing' } - return submission.dispatchState === 'pending' - ? 'pending' - : { cursor: journal.cursor(), value: { clientMessageId, submission } } + const waiting = + until === 'handed-over' + ? isQueuedAgentJournalSubmission(submission) + : submission.dispatchState === 'pending' + return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } } function abortError(signal: AbortSignal): Error { @@ -52,17 +72,19 @@ export class StructuredAgentSessionSendSettlement { private readonly waiters = new Map>() private waiterCount = 0 - constructor(private readonly journalFor: (sessionId: string) => AgentSessionJournal) {} + constructor(private readonly journalFor: (sessionId: string) => SendSettlementJournal) {} wait = ( sessionId: string, clientMessageId: string, - signal?: AbortSignal + options: SendSettlementWaitOptions = {} ): Promise => { + const { signal } = options + const until = options.until ?? 'answered' if (signal?.aborted) { return Promise.reject(abortError(signal)) } - const immediate = settledSend(this.journalFor(sessionId), clientMessageId) + const immediate = settledSend(this.journalFor(sessionId), clientMessageId, until) if (immediate === 'missing') { return Promise.reject(new Error('agent session send disappeared before settlement')) } @@ -79,12 +101,13 @@ export class StructuredAgentSessionSendSettlement { return new Promise((resolve, reject) => { const waiter: SendSettlementWaiter = { clientMessageId, + until, resolve, reject, timer: setTimeout(() => { this.remove(sessionId, waiter) resolve(undefined) - }, SEND_SETTLEMENT_WAIT_TIMEOUT_MS) + }, options.budgetMs ?? SEND_SETTLEMENT_WAIT_TIMEOUT_MS) } waiter.timer.unref?.() const session = existingSession ?? new Set() @@ -106,7 +129,7 @@ export class StructuredAgentSessionSendSettlement { }) } - publish(sessionId: string, journal: AgentSessionJournal): void { + publish(sessionId: string, journal: SendSettlementJournal): void { const waiters = this.waiters.get(sessionId) if (!waiters) { return @@ -118,6 +141,7 @@ export class StructuredAgentSessionSendSettlement { const result = settledSend( journal, waiter.clientMessageId, + waiter.until, submissions.get(waiter.clientMessageId) ) if (result !== 'pending') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index 638d8a8679f6..1a6e5606c1c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -32,6 +32,28 @@ beforeEach(() => { ;({ store, host, dispatch } = hostTestState()) }) +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +/** A send is accepted, then the session's delivery loop hands it over: wait for the handover's + * outcome, or with `handedOver`, only for the handover itself (an admitted send stays pending). */ +async function delivered(clientMessageId: string, options: { handedOver?: true } = {}) { + let submission: ReturnType[number] | undefined + await vi.waitFor(() => { + submission = hostJournal() + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId) + expect(submission?.handedOverAt).toBeDefined() + if (!options.handedOver) { + expect(submission?.dispatchState).not.toBe('pending') + } + }) + return submission! +} + describe('send', () => { it('writes the submission before dispatching and resolves it accepted', async () => { await attach() @@ -43,7 +65,15 @@ describe('send', () => { if (!result.ok) { throw new Error(`expected a send, got ${result.refusal.code}`) } - expect(result.value.submission.dispatchState).toBe('accepted') + // Answered once accepted; the delivery loop hands it over after. + expect(result.value.submission).toMatchObject({ + dispatchState: 'pending', + handoverRecorded: true + }) + expect(result.value.submission.handedOverAt).toBeUndefined() + await expect(delivered(result.value.clientMessageId)).resolves.toMatchObject({ + dispatchState: 'accepted' + }) expect(dispatch).toHaveBeenCalledTimes(1) const page = host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(1) @@ -75,11 +105,11 @@ describe('send', () => { await attach() dispatch.mockRejectedValueOnce(new Error('socket closed')) const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' }) - expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) }) it('replays a retried send from the journal without dispatching twice', async () => { @@ -87,6 +117,7 @@ describe('send', () => { const body = hostTestMessage('add a retry') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) const retry = await host.send(CALLER, params) expect(retry).toMatchObject({ ok: true, replayed: true }) expect(dispatch).toHaveBeenCalledTimes(1) @@ -98,10 +129,9 @@ describe('send', () => { const body = hostTestMessage('possibly delivered') const params = { envelope: envelope('agentSession.send', { body }), body } - const first = await host.send(CALLER, params) - expect(first).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' }) // A thrown adapter call is indistinguishable from a lost reply, so Retry // replays the recorded outcome. @@ -129,6 +159,7 @@ describe('send', () => { const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ ok: true, value: { @@ -151,21 +182,18 @@ describe('send', () => { const body = hostTestMessage('never written') const params = { envelope: envelope('agentSession.send', { body }), body } - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - value: { - submission: { dispatchState: 'rejected', reason: 'provider_write_failed: broken pipe' } - } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'rejected', + reason: 'provider_write_failed: broken pipe' }) // What the user's Retry does with a rejection: a fresh client message id, // which is a first delivery by construction and cannot duplicate the frame // that never left the process. - await expect( - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'accepted' } } + const rotated = { envelope: envelope('agentSession.send', { body }), body } + await expect(host.send(CALLER, rotated)).resolves.toMatchObject({ ok: true, replayed: false }) + await expect(delivered(rotated.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'accepted' }) expect(dispatch).toHaveBeenCalledTimes(2) const state = host.history({ sessionId: SESSION, direction: 'tail' }) @@ -183,10 +211,9 @@ describe('send', () => { const body = hostTestMessage('a message the provider may already hold') const params = { envelope: envelope('agentSession.send', { body }), body } - const first = await host.send(CALLER, params) - expect(first).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' }) // No `unknown` is re-delivered under its own id, whatever its reason says, // so Retry replays the recorded outcome instead of writing again. @@ -203,6 +230,7 @@ describe('send', () => { const body = hostTestMessage('settled for good') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) const journal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal @@ -223,7 +251,7 @@ describe('send', () => { expect(journal.receiptFor(params.envelope.clientOperationId)).not.toBeNull() }) - it('leaves an admitted send pending and writes no dispatch row', async () => { + it('leaves an admitted send pending once handed over', async () => { await attach() dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) const body = hostTestMessage('queued behind a running turn') @@ -233,9 +261,9 @@ describe('send', () => { ok: true, value: { submission: { dispatchState: 'pending', reason: null, resolvedAt: null } } }) - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal + await delivered(params.envelope.clientOperationId, { handedOver: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + const journal = hostJournal() expect(journal.pendingSubmissions()).toHaveLength(1) }) @@ -245,6 +273,8 @@ describe('send', () => { const body = hostTestMessage('written, never acknowledged') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId, { handedOver: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) const journal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal @@ -281,6 +311,8 @@ describe('send', () => { const params = { envelope: envelope('agentSession.send', { body }), body } await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') + // The submission was recorded before the ledger write failed, so it is still delivered. + await delivered(params.envelope.clientOperationId) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, @@ -333,9 +365,8 @@ describe('send', () => { await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') settlement.mockRestore() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal + await delivered(params.envelope.clientOperationId) + const journal = hostJournal() await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) expect(journal.submissions()).toHaveLength(0) @@ -362,6 +393,7 @@ describe('send', () => { const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) expect(dispatch).toHaveBeenCalledTimes(1) await store.recordOperationOutcome({ callerKey: CALLER.callerKey, @@ -393,6 +425,8 @@ describe('send', () => { const body = hostTestMessage('written, then the child died') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId, { handedOver: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) const journal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal @@ -411,21 +445,25 @@ describe('send', () => { it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => { await attach() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed')) + const journal = hostJournal() + const resolve = journal.resolveDispatch.bind(journal) + // The handover row lands; the provider's answer, written after the adapter took the + // message, does not. + vi.spyOn(journal, 'resolveDispatch') + .mockImplementationOnce(resolve) + .mockRejectedValueOnce(new Error('journal resolve failed')) const body = hostTestMessage('possibly delivered before persistence failed') const params = { envelope: envelope('agentSession.send', { body }), body } - await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed') - expect(journal.submissions()).toMatchObject([ - { clientMessageId: params.envelope.clientOperationId, dispatchState: 'unknown' } - ]) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true }) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' + }) + // Acceptance is what the ledger answers for; delivery is the journal's to say. expect( store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) ?.outcome - ).toEqual({ status: 'unknown' }) + ).toMatchObject({ status: 'succeeded' }) expect(dispatch).toHaveBeenCalledTimes(1) await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1) @@ -446,7 +484,7 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('admits a send fenced to another generation, and replays it by id once the fence catches up', async () => { + it('admits a send fenced to another generation, delivers it once, and replays it by id', async () => { const record = await attach() const body = hostTestMessage('add a retry') const params = { @@ -457,10 +495,9 @@ describe('send', () => { ), body } - expect(await host.send(CALLER, params)).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'accepted' } } + expect(await host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'accepted' }) const retry = { ...params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 5780359a8aec..8a7a6ad76473 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -322,6 +322,8 @@ describe('settled attach retry', () => { } const first = await host.send(CALLER, unknownParams) expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // Handed over before the host dies: that is what makes the restart's answer doubt. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) await host.flushAllStreamedEvents() store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) @@ -351,7 +353,7 @@ describe('settled attach retry', () => { if (!sent.ok) { throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) } - expect(dispatch).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) const restoredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) if (!restoredHistory.ok) { throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts new file mode 100644 index 000000000000..516122c091c2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -0,0 +1,48 @@ +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { boundJournalStatusText } from '../agent-session-journal/journal-prompt-body-bounds' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +/** + * A start the delivery loop needed and did not get: one error row in the chat, so the reason + * outlives any error strip, and every queued message rejected with the same words. Keyed by the + * oldest message it failed, so a replayed failure adds no second row. Writes nothing when nothing + * is still queued: a start whose messages Stop withdrew did not fail anyone. + */ +export async function recordStructuredAgentSessionStartFailure( + session: Pick, + text: string +): Promise { + const oldest = oldestQueuedSubmission(session) + if (!oldest) { + return + } + const settlementId = `start-failure:${oldest.clientMessageId}` + await session.journal.appendLifecycleBatch({ + settlementId, + fence: session.fence, + recovered: true, + mutations: [ + { + kind: 'item', + identity: { provider: 'orca', clientMessageId: settlementId }, + body: { kind: 'status', text: boundJournalStatusText(text), tone: 'error' } + } + ] + }) + await session.journal.rejectQueuedSubmissions(session.fence, text) +} + +export function oldestQueuedSubmission( + session: Pick +): ReturnType[number] | undefined { + let oldest: ReturnType + for (const submission of session.journal.submissions()) { + if ( + isQueuedAgentJournalSubmission(submission) && + (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) + ) { + oldest = submission + } + } + return oldest +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts index df6e6cec4bf1..ef612b3a5081 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts @@ -1,7 +1,8 @@ // A Claude chat is published before its CLI answers initialize, and a message sent in that window -// is held until it does. Switching away from the chat starts the release clock; the clock must -// treat that held message as work still owed, exactly as it treats a running turn, or it evicts -// the session and refuses a message the user already sent. +// is accepted and stays queued until it does; the delivery loop hands it over once startup lands. +// Switching away from the chat starts the release clock; the clock must treat that queued message +// as work still owed, exactly as it treats a running turn, or it evicts the session and rejects a +// message the user already sent. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -62,7 +63,7 @@ beforeEach(async () => { lifecycle.push(host.handleAdapterEvent(mapped)) } }, - // As the runtime wires it: a held prompt's outcome reaches the journal out of band. + // As the runtime wires it: an admitted prompt's outcome reaches the journal out of band. onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), // Initialize answers only when the test says so. openConnection: async (launch, handlers) => { @@ -136,13 +137,21 @@ function dispatchState(clientMessageId: string): string | undefined { .submissions.find((entry) => entry.clientMessageId === clientMessageId)?.dispatchState } +/** The delivery loop hands a message over on its own serialized steps after startup lands; this + * yields to them without advancing the (possibly faked) release clock. */ +async function untilSent(connection: { sent: unknown[] }): Promise { + for (let turn = 0; turn < 2000 && connection.sent.length === 0; turn += 1) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + /** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ function waitOutSeveralGraceWindows(): Promise { return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) } describe('a chat left while its Claude CLI is still starting', () => { - it('keeps the session for a message it is holding, and delivers it once startup lands', async () => { + it('keeps the session for a message still queued, and delivers it once startup lands', async () => { await attachStarting() const held = await send('sent while starting') @@ -154,9 +163,12 @@ describe('a chat left while its Claude CLI is still starting', () => { expect(dispatchState(held)).toBe('pending') landInit() - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await vi.waitFor( + () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), + { timeout: 3000 } + ) await vi.waitFor(() => expect(dispatchState(held)).toBe('accepted')) }) @@ -175,8 +187,9 @@ describe('a chat left while its Claude CLI is still starting', () => { // Startup lands just before the clock's next tick. landInit() - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) + await untilSent(connection) expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) await vi.advanceTimersByTimeAsync(GRACE_MS - 1) @@ -192,8 +205,9 @@ describe('a chat left while its Claude CLI is still starting', () => { it('is released after the grace once its turn has finished', async () => { await attachStarting() landInit() - await adapter.drainStartup(SESSION) - await send('answered', 'accepted') + await adapter.awaitStarted(SESSION) + const answered = await send('answered') + await vi.waitFor(() => expect(dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ type: 'result', subtype: 'success', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 0fd544ec9eea..c7f61d5c9ae7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -27,7 +27,8 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectPendingSubmissions: vi.fn(async () => []) + rejectPendingSubmissions: vi.fn(async () => []), + rejectQueuedSubmissions: vi.fn(async () => []) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index cb958d8364ff..c8b8839318ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -261,6 +261,8 @@ describe('a chat that closes', () => { if (!result.ok) { throw new Error('send was refused') } + // Handed over first: a message still queued at close is rejected as never sent instead. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) await host.close(SESSION) @@ -459,6 +461,12 @@ describe('a session evicted and opened again', () => { }) }) +function submissionState(clientMessageId: string): string | undefined { + return host + .journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === clientMessageId)?.dispatchState +} + describe('an unexpected provider exit', () => { it('publishes terminal settlement to a waiting older client', async () => { await attach() @@ -475,6 +483,8 @@ describe('an unexpected provider exit', () => { if (!result.ok) { throw new Error('send was refused') } + // Accepted first; the exit must meet a message the provider was handed. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -536,12 +546,14 @@ describe('an unexpected provider exit', () => { await host.hold(SESSION, SURFACE) dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) const unknownBody = hostTestMessage('message with unknown delivery') + const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) await expect( - host.send(CALLER, { - envelope: envelope('agentSession.send', { body: unknownBody }), - body: unknownBody - }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) + ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. + await vi.waitFor(() => + expect(submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') + ) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -570,9 +582,12 @@ describe('an unexpected provider exit', () => { providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } }) const body = hostTestMessage('a distinct next message') - await expect( - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + const nextEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + await vi.waitFor(() => expect(submissionState(nextEnvelope.clientOperationId)).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(2) }) @@ -637,8 +652,11 @@ describe('an unexpected provider exit', () => { } await expect(host.send(CALLER, unknownParams)).resolves.toMatchObject({ ok: true, - value: { submission: { dispatchState: 'unknown' } } + value: { submission: { dispatchState: 'pending' } } }) + await vi.waitFor(() => + expect(submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') + ) const runtimeState = ( host as unknown as { runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } @@ -682,9 +700,12 @@ describe('an unexpected provider exit', () => { providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } }) const body = hostTestMessage('a distinct next message after failed-barrier recovery') - await expect( - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + const nextEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + await vi.waitFor(() => expect(submissionState(nextEnvelope.clientOperationId)).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(2) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index e8103dac62fc..9f2cbb0e4b64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -25,11 +25,7 @@ import type { } from './structured-agent-session-adapter' import { providerStartupFailureRejection } from './structured-agent-session-dead-generation-settlement' import { validatePendingPrompt } from './structured-agent-session-prompt-state' -import { withTimeout } from '../../../shared/promise-timeout-fallback' -import { - AgentSessionPreDispatchError, - AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS -} from './structured-agent-session-operation-settlement' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -47,8 +43,6 @@ export type AgentSessionTurnContext = { publish: () => void /** Drains provider lifecycle already accepted by the execution host. */ flushStreamedEvents: () => Promise - /** Re-derives authorization after submission persistence, immediately before provider dispatch. */ - beforeDispatch?: () => void /** What the host holds about the child this dispatch is for, read at the moment it is needed. */ providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined now: () => number @@ -67,10 +61,10 @@ function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal * accepted nothing (input is written only after it initializes), so a dispatch it could not * take is provably unwritten and is rejected with the cause the adapter gave. */ async function dispatchSafely( - ctx: AgentSessionTurnContext, + ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, - requestedAt: number | undefined + requestedAt: number ): Promise { try { return await ctx.adapter.dispatch({ @@ -78,13 +72,9 @@ async function dispatchSafely( clientMessageId, body, fence: ctx.fence, - ...(ctx.beforeDispatch ? { beforeDispatch: async () => ctx.beforeDispatch?.() } : {}), - ...(requestedAt === undefined ? {} : { requestedAt }) + requestedAt }) } catch (error) { - if (error instanceof AgentSessionPreDispatchError) { - throw error - } if (ctx.providerChildPhase?.() === 'starting') { return { state: 'rejected', reason: providerStartupFailureRejection(error) } } @@ -111,6 +101,8 @@ async function appendStatus( * the whole content of the word — and one message reached the model five times * when this was a judgement call instead of an invariant. A distinct send after * a terminal rejection uses a fresh id, which is a first delivery. + * + * Accepting only records the message; the session's delivery loop hands it over. */ export async function performSend( ctx: AgentSessionTurnContext, @@ -133,86 +125,77 @@ export async function performSend( } } try { - await ctx.journal.appendSubmission({ ...input, fence: ctx.fence }) + await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch { return invalid('The message could not be recorded and was not sent.') } + return { + ok: true, + value: { + clientMessageId: input.clientMessageId, + submission: requireSubmission(ctx, input.clientMessageId) + } + } +} - // The row just written is the send's instant on the host clock; the turn this +export type AgentSessionHandoverContext = Pick< + AgentSessionTurnContext, + 'sessionId' | 'journal' | 'fence' | 'adapter' | 'providerChildPhase' +> + +/** + * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash + * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. + */ +export async function handOverSubmission( + ctx: AgentSessionHandoverContext, + submission: AgentJournalSubmission +): Promise { + const { clientMessageId } = submission + const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) + if (body?.kind !== 'message') { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + reason: 'The message could not be read back and was not sent.', + fence: ctx.fence + }) + return + } + await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence }) + // The row written at acceptance is the send's instant on the host clock; the turn this // dispatch opens records it so the live counter never re-anchors at turn-open. - const requestedAt = ctx.journal - .submissions() - .find((entry) => entry.clientMessageId === input.clientMessageId)?.submittedAt - const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body, requestedAt).catch( - async (error: unknown) => { - if (error instanceof AgentSessionPreDispatchError) { - const recorded = await withTimeout( - ctx.journal - .resolveDispatch({ - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: error.message, - fence: ctx.fence - }) - .then(() => true), - AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS, - false - ) - if (!recorded) { - console.warn('[structured-agent-session] pre-dispatch refusal persistence failed') - } - } - throw error - } - ) + const outcome = await dispatchSafely(ctx, clientMessageId, body, submission.submittedAt) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { - return { - ok: true, - value: { - clientMessageId: input.clientMessageId, - submission: requireSubmission(ctx, input.clientMessageId) - } - } + return } try { await ctx.journal.resolveDispatch( outcome.state === 'accepted' ? { - clientMessageId: input.clientMessageId, + clientMessageId, state: 'accepted', providerIdentity: outcome.providerIdentity, fence: ctx.fence } - : { - clientMessageId: input.clientMessageId, - state: outcome.state, - reason: outcome.reason, - fence: ctx.fence - } + : { clientMessageId, state: outcome.state, reason: outcome.reason, fence: ctx.fence } ) } catch (error) { // A failed resolution must not strand a pending row; an unknown result is // explicitly replayable. try { await ctx.journal.resolveDispatch({ - clientMessageId: input.clientMessageId, + clientMessageId, state: 'unknown', reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, fence: ctx.fence }) } catch { - // Nothing further to record; the pending row is settled on the next attach. + // Nothing further to record; the pending row is settled on the next open. } throw error } - return { - ok: true, - value: { - clientMessageId: input.clientMessageId, - submission: requireSubmission(ctx, input.clientMessageId) - } - } } function requireSubmission( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 4ba1a93169b0..bba6154af437 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -136,7 +136,8 @@ describe('provider-exit recovery tickets', () => { items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), appendLifecycleBatch, - markPendingSubmissionsUnknown: vi.fn(async () => []) + markPendingSubmissionsUnknown: vi.fn(async () => []), + rejectQueuedSubmissions: vi.fn(async () => []) } } as unknown as StructuredAgentSessionHostSession @@ -204,7 +205,8 @@ describe('provider-exit recovery tickets', () => { journal: { snapshot: () => ({ items }), appendLifecycleBatch, - markPendingSubmissionsUnknown: vi.fn(async () => []) + markPendingSubmissionsUnknown: vi.fn(async () => []), + rejectQueuedSubmissions: vi.fn(async () => []) } } as unknown as StructuredAgentSessionHostSession const store = { @@ -312,7 +314,8 @@ describe('provider-exit recovery tickets', () => { snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectPendingSubmissions: vi.fn(async () => []) + rejectPendingSubmissions: vi.fn(async () => []), + rejectQueuedSubmissions: vi.fn(async () => []) } } @@ -370,6 +373,7 @@ describe('provider-exit recovery tickets', () => { appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, rejectPendingSubmissions: vi.fn(async () => []), + rejectQueuedSubmissions: vi.fn(async () => []), submissions: () => [{ clientMessageId: 'client-1', dispatchState: 'pending' }] } } @@ -416,6 +420,7 @@ describe('provider-exit recovery tickets', () => { journal: { markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), + rejectQueuedSubmissions: vi.fn(async () => []), snapshot: () => ({ items: [lifecycleItem('turn-failing', 1, { state: 'running', startedAt: 1 })] }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 8eac02dc17b7..5a3c2497c348 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -17,6 +17,7 @@ import { agentSessionProviderHandleRoot } from '../../../shared/agent-session-provider-handle' import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker, @@ -54,6 +55,8 @@ function pendingSubmissionInFlight( if ( submission && submission.recovered !== true && + // A queued message reached no agent, so there is no work of its to resume. + !isQueuedAgentJournalSubmission(submission) && (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') ) { return submission diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts index 2199da053922..64f1ee652e78 100644 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts @@ -9,12 +9,21 @@ export async function recoverInterruptedCompaction( fence: number ): Promise { const command = store.getRecord(sessionId)?.conversationCommand - if ( - command?.command !== 'compact' || - command.phase !== 'prepared' || - command.runtimeFence === undefined || - command.runtimeFence === fence - ) { + if (command?.runtimeFence === undefined || command.runtimeFence === fence) { + return + } + await settleInterruptedCompaction(store, sessionId, journal, fence) +} + +/** A compaction still prepared whose child can no longer finish it: its outcome is unknown. */ +export async function settleInterruptedCompaction( + store: AgentSessionRecordStore, + sessionId: string, + journal: AgentSessionJournal, + fence: number +): Promise { + const command = store.getRecord(sessionId)?.conversationCommand + if (command?.command !== 'compact' || command.phase !== 'prepared') { return } const error = 'Previous compaction completion could not be confirmed after session recovery.' diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 78b9f4a59d0f..fe6e55019759 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -48,9 +48,12 @@ export function conversationCommandBlocked( : 'Wait for background tasks to finish before using this command.' } if ( - ctx.journal - .submissions() - .some((entry) => entry.dispatchState === 'pending' || entry.dispatchState === 'unknown') + ctx.journal.submissions().some( + (entry) => + entry.dispatchState === 'pending' || + // Doubt left by an earlier child is not this one's work in flight. + (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) + ) ) { return 'Resolve pending or unconfirmed messages before using this command.' } diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index e7a4165120ed..f16d48efb9d8 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -36,6 +36,7 @@ export type AgentSessionMutationOperationAdmission = { hostFingerprint: string now: number operationIdScope?: 'global' + conversationWrite?: true } export type AgentSessionMutationOperationDecision = { @@ -149,7 +150,8 @@ export function admitAgentSessionMutationOperation( envelope: args.envelope, hostFingerprint: args.hostFingerprint, ledger: ledger.decision, - lease: record.lease + lease: record.lease, + ...(args.conversationWrite ? { conversationWrite: true } : {}) }) if (ledger.decision.decision === 'admit' && admission.decision === 'refused') { ledger.rows.delete(agentSessionOperationKey(operation.callerKey, operation.operationId)) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index 19e058110553..da1135f588f4 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -1,8 +1,8 @@ // A Claude chat is published the moment its child spawns, before the CLI has answered initialize. -// A send in that window — into a fresh start, or into the restart a send itself asked for after -// a start that failed — is admitted and held until the child proves its start. When the CLI dies -// first, the held message is rejected with the CLI's own diagnostic, the chat shows the cause -// once, and nothing is left as a delivery nobody can confirm. Against the production runtime, +// A send in that window — into a fresh start, or into the restart the delivery loop makes for a +// send after a start that failed — is accepted and stays queued until the child proves its start. +// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, the chat +// shows the cause once, and nothing is left as a delivery nobody can confirm. Against the production runtime, // adapter, record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -39,7 +39,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { expect(statusRows(host)).toEqual([expect.stringContaining('not signed in')]) const releasedFence = fence(host) - // The send asks for the child back and is held for its start; the CLI dies again first. + // The delivery loop asks for the child back and the message waits for its start; the CLI + // dies again first. const held = await send(host, 'hello?') - expect(claude.children(SESSION)).toHaveLength(2) - expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + ) await failLatestStart(host, 2) // Rejected with the cause, not left in doubt; one row for this attempt names it. @@ -111,8 +114,8 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { // The user signs in and retries: one restart, proven, written to the CLI. claude.behave(SESSION, {}) await send(host, 'hello again') - expect(claude.children(SESSION)).toHaveLength(3) - expect(fence(host)).toBe(releasedFence + 3) + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(3)) + await vi.waitFor(() => expect(fence(host)).toBe(releasedFence + 3)) await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') @@ -122,7 +125,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { }) describe('a send while the first Claude start is still answering initialize', () => { - it('is held, and written once the CLI proves its start', async () => { + it('is queued, and written once the CLI proves its start', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) @@ -130,7 +133,7 @@ describe('a send while the first Claude start is still answering initialize', () await send(host, 'hello') expect(claude.child(SESSION).calls).not.toContain('send') - // The CLI answers: startup lands and the held message is written to the proven child. + // The CLI answers: startup lands and the queued message is written to the proven child. claude.child(SESSION).answerInit() await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 0d4b49096379..817b00144bca 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -1,10 +1,9 @@ -// A send restarts a chat's Claude child and is admitted against it while it is still starting. -// When that child dies before the send's dispatch reaches the adapter, the adapter has no session -// to hold the message for, so the dispatch throws. A child that never proved its start accepted -// nothing — input is only written after initialize — so the send settles `rejected` with the -// child's own diagnostic, never as a delivery nobody can confirm, and a client that was -// subscribed the whole time receives the failure row and the rejected submission over the wire. -// Against the production runtime, adapter, record store and host, with only the CLI scripted. +// A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child. +// When that child dies before it proves its start, the message was never handed to it — delivery +// waits for the start — so the send settles `rejected` with the child's own diagnostic, never as a +// delivery nobody can confirm, and a client that was subscribed the whole time receives the +// failure row and the rejected submission over the wire. Against the production runtime, adapter, +// record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -18,6 +17,11 @@ const SESSION = 'claude-send-restart-dies-first' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => void): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 /** The dispatch state each send was answered with, before any exit settled it. */ @@ -86,24 +90,14 @@ function submission(host: StructuredAgentSessionHost, clientMessageId: string) { } async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { - await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(count)) + await eventually(() => expect(claude.children(SESSION)).toHaveLength(count)) claude.child(SESSION).exit(new Error(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => + await eventually(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') ) } -/** The restarted child dies the instant the send's dispatch reaches the adapter. */ -function killChildAtDispatch(host: StructuredAgentSessionHost): void { - const adapter = host.deps.adapter - const dispatch = adapter.dispatch.bind(adapter) - vi.spyOn(adapter, 'dispatch').mockImplementationOnce((input) => { - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) - return dispatch(input) - }) -} - /** Everything a subscriber received, flattened to the rows and submissions it was shown. */ function received(events: AgentSessionSubscribeEvent[]) { const statusTexts: string[] = [] @@ -129,7 +123,7 @@ function received(events: AgentSessionSubscribeEvent[]) { return { statusTexts, submissions, fences } } -describe('a send whose restarted Claude child dies before the dispatch reaches the adapter', () => { +describe('a send whose restarted Claude child dies before it proves its start', () => { it('settles rejected with the diagnostic, keeps one failure row, and a Retry is one new attempt', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() @@ -139,18 +133,13 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t await failLatestStart(host, 1) const releasedFence = fence(host) - killChildAtDispatch(host) const sent = await send(host, 'hello?') - // The send's own answer already says it was not delivered; it does not wait for the exit. - expect(answered.get(sent)).toBe('rejected') - expect(claude.children(SESSION)).toHaveLength(2) - await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - ) + // Accepted: the answer comes before the restart it needs. + expect(answered.get(sent)).toBe('pending') + await failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". - await vi.waitFor(() => + await eventually(() => expect(submission(host, sent)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. @@ -168,40 +157,14 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t // Retry under a new id: one restart, and once the CLI is healthy the message is written. claude.behave(SESSION, {}) await send(host, 'hello again') - expect(claude.children(SESSION)).toHaveLength(3) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + await eventually(() => expect(claude.children(SESSION)).toHaveLength(3)) + await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) expect(statusRows(host)).toHaveLength(2) }) - it('names the diagnostic even when the exit was fully processed before the dispatch arrived', async () => { - claude.behave(SESSION, { initHangs: true }) - const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ - ok: true - }) - await failLatestStart(host, 1) - const adapter = host.deps.adapter - const dispatch = adapter.dispatch.bind(adapter) - vi.spyOn(adapter, 'dispatch').mockImplementationOnce(async (input) => { - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) - // The adapter settles and publishes the exit; the host's own settlement waits behind this send. - await new Promise((resolve) => setTimeout(resolve, 300)) - return dispatch(input) - }) - - const sent = await send(host, 'hello?') - expect(answered.get(sent)).toBe('rejected') - await waitForStructuredAgentSessionRecovery() - expect(submission(host, sent)).toMatchObject({ - dispatchState: 'rejected', - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` - }) - expect(statusRows(host)).toHaveLength(2) - }) - // A restart refused because its child died before it was handed over leaves one row, from the - // send, in the words any failed start uses. + // delivery, in the words any failed start uses, and rejects the message with them. it.each(['spawn', 'start-time-read'] as const)( 'leaves one row for a restart whose child exits at %s', async (at) => { @@ -213,10 +176,13 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t await failLatestStart(host, 1) claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: DIAGNOSTIC, at } }) - await expect(attempt(host, 'hello?')).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } - }) + const sent = await send(host, 'hello?') + await eventually(() => + expect(submission(host, sent)).toMatchObject({ + dispatchState: 'rejected', + reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + }) + ) await waitForStructuredAgentSessionRecovery() expect(statusRows(host)).toEqual([ @@ -242,14 +208,10 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t }) try { await failLatestStart(host, 1) - killChildAtDispatch(host) const sent = await send(host, 'hello?') - await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - ) + await failLatestStart(host, 2) - await vi.waitFor(() => { + await eventually(() => { const seen = received(events) expect(seen.submissions.get(sent)).toBe('rejected') expect(seen.statusTexts).toContainEqual( @@ -285,7 +247,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o return rows } - it('shows one row naming the cause per failed attempt, admitted or refused, and none once the CLI is fixed', async () => { + it('shows one row naming the cause per failed attempt, however the start died, and none once the CLI is fixed', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() const events: AgentSessionSubscribeEvent[] = [] @@ -301,37 +263,38 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o }) try { await failLatestStart(host, 1) - await vi.waitFor(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE])) + await eventually(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE])) - // Send: admitted against the restarted child, which dies before starting. - killChildAtDispatch(host) - const sent = await attempt(host, 'hello?') - await waitForStructuredAgentSessionRecovery() - expect(sent).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'rejected', reason: STARTUP_FAILURE } } - }) - await vi.waitFor(() => + // Send: accepted, and its delivery restarts the child, which dies before starting. + const sent = await send(host, 'hello?') + await failLatestStart(host, 2) + await eventually(() => + expect(submission(host, sent)).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_FAILURE + }) + ) + await eventually(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE, STARTUP_FAILURE]) ) - // Retry while still broken: this restart dies before its child is handed over, so the send - // is refused before admission. Still one row, saying the same thing. + // Retry while still broken: this restart dies before its child is handed over, so the + // delivery's start is refused. Still one row, saying the same thing, on the rejected message. claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: 'claude stream-json exited (code 1): claude: not signed in (rig)', at: 'start-time-read' } }) - await expect(attempt(host, 'hello?')).resolves.toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: expect.stringMatching(/couldn't restart: .*not signed in \(rig\)/) - } - }) + const retried = await send(host, 'hello?') + await eventually(() => + expect(submission(host, retried)).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_FAILURE + }) + ) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => + await eventually(() => expect([...shownRows(events).values()]).toEqual([ STARTUP_FAILURE, STARTUP_FAILURE, @@ -342,7 +305,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o // The CLI is fixed: Retry delivers and adds no row. claude.behave(SESSION, {}) await expect(attempt(host, 'hello?')).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(shownRows(events).size).toBe(3) } finally { unsubscribe() diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 91bf1158e077..c530a1e84abf 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -91,7 +91,7 @@ describe('structured mailbox pointer host', () => { value: { submission: { dispatchState } } }) ) - hostRef.current = { send } + hostRef.current = { send, waitForSendSettlement: async () => undefined } await expect( createStructuredMailboxPointerHost().send({ sessionId: 's1', @@ -107,6 +107,28 @@ describe('structured mailbox pointer host', () => { expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined() }) + it('consumes mail once an accepted nudge is delivered while the worker starts (W10)', async () => { + hostRef.current = { + send: async () => ({ + ok: true, + value: { clientMessageId: 'op1', submission: { dispatchState: 'pending' } } + }), + waitForSendSettlement: async () => ({ + value: { clientMessageId: 'op1', submission: { dispatchState: 'accepted' } } + }) + } + await expect( + createStructuredMailboxPointerHost().send({ + sessionId: 's1', + dispatchId: 'd1', + operationId: 'op1', + expectedRuntimeFence: 1, + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [] } + } as never) + ).resolves.toEqual({ kind: 'sent', state: 'accepted' }) + }) + it('scopes direct peer mail to the session when there is no dispatch to scope to', async () => { // Direct mail is addressed to the worker's own handle, so there may be no dispatch at all. // The ledger is keyed on (callerKey, operationId): a key derived from the session keeps that diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index b722952df92f..907a7bcc0975 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -6,6 +6,7 @@ * the send and reports what the host said. */ +import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredMailboxPointerHost } from './structured-mailbox-pointer-delivery' @@ -94,8 +95,19 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo ? { kind: 'unattached' } : { kind: 'sent', state: 'rejected' } } - // `pending` is not yet an acknowledgement; only `accepted` may consume mail. - const state = result.value.submission.dispatchState + // `pending` is not yet an acknowledgement; only `accepted` may consume mail. Accepted is not + // delivered, so wait out a start; a wait that runs out parks for the next journal edge. + const submission = + result.value.submission.dispatchState === 'pending' + ? (( + await host + .waitForSendSettlement(input.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value.submission ?? result.value.submission) + : result.value.submission + const state = submission.dispatchState return { kind: 'sent', state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : 'unknown' 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 493f9d092c36..7f8fd0fea0dd 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 @@ -224,10 +224,17 @@ describe('structured worker session hold', () => { }) describe('structured worker dispatch preamble', () => { - function hostWithSubmission(submission: Record) { + function hostWithSubmission( + submission: Record, + delivered?: Record + ) { return { deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, - send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }) + send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }), + // What the submission settled as while the worker's agent started; undefined when the + // start outlasted the wait. + waitForSendSettlement: async () => + delivered ? { value: { clientMessageId: 'c1', submission: delivered } } : undefined } as never } @@ -240,6 +247,17 @@ describe('structured worker dispatch preamble', () => { ).resolves.toBeUndefined() }) + it('waits for an accepted preamble to be delivered, and reports that delivery (W10)', async () => { + await expect( + send( + hostWithSubmission( + { dispatchState: 'pending', reason: null }, + { dispatchState: 'accepted', reason: null } + ) + ) + ).resolves.toBeUndefined() + }) + it('never claims delivery for a submission the provider never acknowledged', async () => { // `dispatchSafely` turns ANY thrown adapter call — provider child dead, transport dropped — // into `unknown`, and `performSend` still returns ok. Reporting that as `dispatch_input: 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 5bde461a0595..a889ffb58f1b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -14,6 +14,7 @@ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' +import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -244,7 +245,18 @@ export async function sendStructuredWorkerPreamble(args: { if (!result.ok) { throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`) } - const submission = result.value.submission + // Accepted is not delivered: the worker's agent may still be starting. Never re-sent; a wait + // that runs out is reported as unknown below and settles when the submission does. + const submission = + result.value.submission.dispatchState === 'pending' + ? (( + await args.host + .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value.submission ?? result.value.submission) + : result.value.submission if (submission.dispatchState === 'accepted') { return } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts new file mode 100644 index 000000000000..9ae846df6fc0 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -0,0 +1,84 @@ +// Which clients get a send answered at acceptance, and which have their reply held until the +// message is handed over: a client that cannot show a rejection after `pending` must not see one. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' +import { + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + SESSION, + sendParams, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(() => { + installStructuredHostStub() +}) + +afterEach(() => { + clearStructuredHostStub() +}) + +describe('agentSession.send reply timing', () => { + it('holds a pending reply until handover for a client that cannot show a later rejection', async () => { + hostCalls.send.mockResolvedValueOnce(pendingSendResult()) + hostCalls.waitForSendSettlement.mockResolvedValueOnce(undefined) + + await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + + expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(SESSION, 'client-1', { + until: 'handed-over', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS + }) + }) + + it('answers at acceptance for the local desktop and paired desktop clients (W2)', async () => { + for (const clientCapabilities of [ + DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, + // A paired desktop gains the structured surface as its own capability; the reply rule rides + // on the list it already sends. + [...ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + ]) { + hostCalls.send.mockResolvedValueOnce(pendingSendResult()) + const response = await call('agentSession.send', sendParams(), { + clientKind: 'runtime', + clientCapabilities: [...clientCapabilities] + }) + expect(response).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'pending' } } } + }) + } + expect(hostCalls.waitForSendSettlement).not.toHaveBeenCalled() + }) +}) + +function pendingSendResult() { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 1 }, + value: { + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true as const + } + } + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 8b9488699904..5fde5e0f6e49 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -1,26 +1,43 @@ -import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { RpcContext } from '../core' import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' +/** + * A send answers once the host accepts it. A client that predates that answer cannot show a + * message rejected after it, so its reply is held until the message is handed over or rejected; + * one that predates pending replies at all waits, as before, for the provider's answer. + */ export async function sendStructuredAgentSessionForClient( params: Parameters[1], context: RpcContext ) { const host = requireStructuredHost(context) const result = await host.send(structuredCallerFor(context), params) + const capabilities = context.clientCapabilities ?? [] if ( !result.ok || result.value.submission.dispatchState !== 'pending' || context.clientKind === undefined || - context.clientCapabilities?.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) ) { return result } + // The start that used to run before the reply now runs after acceptance, so both waits cover it. const settled = await host.waitForSendSettlement( params.envelope.sessionId, result.value.clientMessageId, - context.signal + { + until: capabilities.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + ? 'handed-over' + : 'answered', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, + ...(context.signal ? { signal: context.signal } : {}) + } ) return settled ? { ...result, ...settled } : result } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 2d2760832add..70da8409c37c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -12,6 +12,7 @@ import { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { ALL_RPC_METHODS } from './index' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' @@ -252,11 +253,11 @@ describe('capability gating', () => { signal: controller.signal }) - expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith( - SESSION, - 'client-1', - controller.signal - ) + expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(SESSION, 'client-1', { + until: 'answered', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, + signal: controller.signal + }) expect(response).toMatchObject({ ok: true, result: { @@ -302,36 +303,6 @@ describe('capability gating', () => { }) }) - it('returns durable pending immediately to clients that understand admission', async () => { - hostCalls.send.mockResolvedValueOnce({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-a', sequence: 1 }, - value: { - clientMessageId: 'client-1', - submission: { - clientMessageId: 'client-1', - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - } - }) - - const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) - - expect(hostCalls.waitForSendSettlement).not.toHaveBeenCalled() - expect(response).toMatchObject({ - ok: true, - result: { value: { submission: { dispatchState: 'pending' } } } - }) - }) - it('requires the host structured-chat setting for mobile clients', async () => { const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 4fa390be0fb9..a4e2500ffc46 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -317,11 +317,14 @@ describe('a structured codex session over agentSession.*', () => { body } + const turnStarts = () => codex.live().calls.filter((entry) => entry.method === 'turn/start') await ok('agentSession.send', params) + // Accepted first; the delivery loop hands it over once. + await vi.waitFor(() => expect(turnStarts()).toHaveLength(1)) const replay = await call('agentSession.send', params) expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) - expect(codex.live().calls.filter((entry) => entry.method === 'turn/start')).toHaveLength(1) + expect(turnStarts()).toHaveLength(1) }) it('joins an acquired attach through journal bind before draining final rows', async () => { diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 3f8adae4906a..d81170ee075b 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -218,6 +218,11 @@ function createIntentParams() { } let codex: CodexScript +/** Accepted first; the delivery loop hands the send over as `turn/start` after the reply. */ +const handedOverAs = (params: Record) => + vi.waitFor(() => + expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params }) + ) let root: string let dispatcher: RpcDispatcher let bootEnvironmentReads: number @@ -466,10 +471,7 @@ describe('a structured codex session over agentSession.*', () => { // send coalesced into a running turn is answered with that turn's id, so // which message landed where is knowable only from the echo. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) - expect(codex.live().calls.at(-1)).toMatchObject({ - method: 'turn/start', - params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } - }) + await handedOverAs({ threadId: THREAD, clientUserMessageId: sent.clientMessageId }) codex.notify('turn/started', { turn: { id: TURN } }) // Codex echoes the message back carrying the `clientId` it was sent under, @@ -557,14 +559,11 @@ describe('a structured codex session over agentSession.*', () => { // "delivery unconfirmed" — it carries no identity yet, because the response // to a coalesced send names the running turn rather than this message. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) - expect(codex.live().calls.at(-1)).toMatchObject({ - method: 'turn/start', - params: { - threadId: THREAD, - clientUserMessageId: sent.clientMessageId, - model: 'gpt-live', - effort: 'high' - } + await handedOverAs({ + threadId: THREAD, + clientUserMessageId: sent.clientMessageId, + model: 'gpt-live', + effort: 'high' }) // ── stream ────────────────────────────────────────────────────────────── 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 7a3736b9009b..0b2c8bd4f4b6 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -128,7 +128,8 @@ describe('structured session runtime provider-exit wiring', () => { await expect( host.send({ callerKey: 'runtime-test' }, { envelope, body }) ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - expect(turn).toBe(1) + // The send answers at acceptance; the delivery loop hands it to the reacquired child after. + await vi.waitFor(() => expect(turn).toBe(1)) }) it('does not reacquire when the production exit callback comes from a requested close', async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx index 5958e2a8d543..74da4709e938 100644 --- a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx +++ b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx @@ -16,9 +16,13 @@ export function NativeChatDeliveryRetry({ retry: (clientMessageId: string) => void }): React.JSX.Element | null { // Why: read through the drain's own rule, so Retry can never name an entry other than the one - // the queue actually stopped on -- which is no longer always the head. + // the queue actually stopped on -- which is no longer always the head. A rejected entry holds + // nothing up, so it is offered only when the queue itself is not stopped. const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) - const retryable = admission.state === 'blocked' ? admission.entry : null + const retryable = + admission.state === 'blocked' + ? admission.entry + : (outbox.find((entry) => entry.state === 'rejected') ?? null) if (!retryable) { return null } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index f208e6cb4a93..2db9fd248901 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -5,6 +5,7 @@ import { isStructuredAgentSessionComposerCommand, isStructuredAgentSessionGoalCommand } from '../../../../shared/structured-agent-session-composer' +import { agentSessionErrorText } from '../../../../shared/agent-session-error-text' import type { AgentType } from '../../../../shared/agent-status-types' import { dispatchNativeChatStructuredComposerText } from './native-chat-structured-composer-dispatch' import { pushHistory, type HistoryState } from './native-chat-composer-state' @@ -83,9 +84,7 @@ export function useNativeChatStructuredComposerSend({ clearSkillOrigin() clearImageAttachments() }) - .catch((error) => - structuredTransport.onError(error instanceof Error ? error.message : String(error)) - ) + .catch((error) => structuredTransport.onError(agentSessionErrorText(error))) }, [ agent, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index d7706913d21d..1ee70c572235 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import * as conversationCommands from './structured-conversation-command-send' +import { agentSessionErrorText } from '../../../../shared/agent-session-error-text' import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' @@ -75,7 +76,7 @@ export function useStructuredAgentSessionMutate(args: { }) } catch (error) { if (enabledRef.current && stateRef.current.fence === targetFence) { - setWriteError(error instanceof Error ? error.message : 'Request was not sent') + setWriteError(agentSessionErrorText(error, 'Request was not sent')) } return null } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index d66f579ed0d8..c1945503d270 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' const mocks = vi.hoisted(() => ({ call: vi.fn() @@ -17,6 +18,28 @@ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session- const REASON = 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in.' +function acceptedResultFor(clientMessageId: string) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: `provider-${clientMessageId}`, + reason: null, + submittedAt: 1, + resolvedAt: 1 + } + } + } +} + function rejectedResultFor(clientMessageId: string) { return { ok: true, @@ -65,7 +88,116 @@ describe('a send the host rejected because the agent never started', () => { act(() => expect(result.current.send('hello')).toBe(true)) await waitFor(() => expect(result.current.error).toBe(REASON)) - expect(result.current.outbox[0]?.state).toBe('queued') - expect(result.current.blockedClientMessageId).toBe(result.current.outbox[0]?.clientMessageId) + // Settled as not delivered: it waits for Retry and holds no later message up. + expect(result.current.outbox[0]?.state).toBe('rejected') + expect(result.current.blockedClientMessageId).toBeNull() + }) + + it('sends a new message past one the host could not start the agent for, without resending it', async () => { + const message = "Claude couldn't restart: Not logged in. Please run /login." + mocks.call.mockImplementation(async (_target, _method, params) => { + const request = params as { + envelope: { clientOperationId: string } + body: { blocks: { text?: string }[] } + } + return request.body.blocks[0]?.text === 'first' + ? { ok: false, refusal: { code: 'agent_session_owner_restart_failed', message } } + : acceptedResultFor(request.envelope.clientOperationId) + }) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + const rejectedId = result.current.outbox[0]!.clientMessageId + + // The user's next message is the retry of the start: it goes out on its own. + act(() => expect(result.current.send('second')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) + + const sent = mocks.call.mock.calls.map( + (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text + ) + expect(sent).toEqual(['first', 'second']) + expect(result.current.outbox.map((entry) => [entry.clientMessageId, entry.state])).toEqual([ + [rejectedId, 'rejected'] + ]) + }) + + it('keeps a message the host accepted and then could not deliver, with its reason and Retry', async () => { + const reason = "Codex couldn't restart: spawn codex ENOENT." + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => pendingResultFor(params.envelope.clientOperationId) + ) + const target = { kind: 'local' } as const + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: NO_SUBMISSIONS } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + const id = result.current.outbox[0]!.clientMessageId + + rerender({ + submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] + }) + + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + expect(result.current.error).toBe(reason) + expect(result.current.blockedClientMessageId).toBeNull() + + // Retry is a new message with the same text: a fresh id, sent once. + act(() => result.current.retry(id)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + const retried: { + envelope: { clientOperationId: string } + body: { blocks: { text?: string }[] } + } = mocks.call.mock.calls[1]![2] + expect(retried.envelope.clientOperationId).not.toBe(id) + expect(retried.body.blocks[0]?.text).toBe('hello') }) }) + +const NO_SUBMISSIONS: AgentJournalSubmission[] = [] + +function pendingResultFor(clientMessageId: string) { + const submission: AgentJournalSubmission = { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true + } + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + clientMessageId, + submission + } + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 613a599c2a62..56c4ee3e765d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -247,7 +247,7 @@ describe('useStructuredAgentSessionOutbox', () => { ) act(() => expect(result.current.send('hello')).toBe(true)) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('queued')) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) const firstId = (mocks.call.mock.calls[0]![2] as { envelope: { clientOperationId: string } }) .envelope.clientOperationId const retryId = result.current.outbox[0]!.clientMessageId @@ -550,7 +550,9 @@ describe('useStructuredAgentSessionOutbox', () => { expect(mocks.call).toHaveBeenCalledOnce() expect(result.current.outbox).toHaveLength(1) - expect(result.current.blockedClientMessageId).toBe(result.current.outbox[0]?.clientMessageId) + // Never sent, and never re-sent on its own: it waits for Retry and holds nothing up. + expect(result.current.outbox[0]?.state).toBe('rejected') + expect(result.current.blockedClientMessageId).toBeNull() // Settled, not pending: the refused id never ran, so a Retry is a new operation. const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId const retryId = result.current.outbox[0]!.clientMessageId @@ -769,8 +771,8 @@ describe('useStructuredAgentSessionOutbox', () => { "Couldn't reach the agent. Your message was not sent — Retry to send it again." ) ) - expect(result.current.outbox[0]?.state).toBe('queued') - expect(result.current.blockedClientMessageId).toBe(firstId) + expect(result.current.outbox[0]?.state).toBe('rejected') + expect(result.current.blockedClientMessageId).toBeNull() // Retry immediately, before the journal subscription can publish the rejected row. act(() => result.current.retry(firstId)) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index e38709b9fed7..9801caa80ad0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -7,7 +7,11 @@ import { reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import type { StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' +import { + rejectedDispatchingSubmission, + structuredAgentSessionRejectionNotice, + type StructuredAgentSessionSendDisposition +} from '../../../../shared/structured-agent-session-send-disposition' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' import { @@ -120,7 +124,10 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGenerationRef.current += 1 inFlightIdRef.current = null } - if (blockedIdRef.current !== null && hostOwns.has(blockedIdRef.current)) { + const rejected = rejectedDispatchingSubmission(current, submissions) + if (rejected) { + setError(structuredAgentSessionRejectionNotice(rejected.reason)) + } else if (blockedIdRef.current !== null && hostOwns.has(blockedIdRef.current)) { blockedIdRef.current = null setError(null) } else if ( diff --git a/src/shared/agent-session-error-text.test.ts b/src/shared/agent-session-error-text.test.ts new file mode 100644 index 000000000000..91fc1899d1d8 --- /dev/null +++ b/src/shared/agent-session-error-text.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionErrorText } from './agent-session-error-text' + +describe('agentSessionErrorText', () => { + it('reads a stream failure payload by its message, not as [object Object]', () => { + expect(agentSessionErrorText({ code: 'runtime_error', message: 'journal unreadable' })).toBe( + 'journal unreadable' + ) + }) + + it('reads a thrown error by its message, without the class-name prefix', () => { + expect(agentSessionErrorText(new Error('journal read failed'))).toBe('journal read failed') + }) + + it('falls back to the code, then to the caller fallback', () => { + expect(agentSessionErrorText({ code: 'agent_session_ownership_unknown' })).toBe( + 'agent_session_ownership_unknown' + ) + expect(agentSessionErrorText({}, 'Request was not sent')).toBe('Request was not sent') + expect(agentSessionErrorText(undefined, 'Request was not sent')).toBe('Request was not sent') + expect(agentSessionErrorText('plain text')).toBe('plain text') + }) +}) diff --git a/src/shared/agent-session-error-text.ts b/src/shared/agent-session-error-text.ts new file mode 100644 index 000000000000..9709cd30a68f --- /dev/null +++ b/src/shared/agent-session-error-text.ts @@ -0,0 +1,22 @@ +// The one way a chat surface turns an agent-session failure into words. +// +// A unary RPC throws an `Error`, but a stream hands its failure over as the raw `{ code, message }` +// payload, and `String()` of that is `[object Object]` — which is what a failed chat used to show. + +export function agentSessionErrorText(error: unknown, fallback = 'Something went wrong.'): string { + if (error instanceof Error) { + return error.message || fallback + } + if (typeof error === 'string') { + return error || fallback + } + if (typeof error === 'object' && error !== null) { + if ('message' in error && typeof error.message === 'string' && error.message.length > 0) { + return error.message + } + if ('code' in error && typeof error.code === 'string' && error.code.length > 0) { + return error.code + } + } + return fallback +} diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 29271faa2b1b..a978f469547d 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -295,7 +295,9 @@ export const AgentJournalSubmissionSchema = z.object({ reason: z.string().nullable(), submittedAt: z.number(), resolvedAt: z.number().nullable(), - recovered: z.literal(true).optional() + recovered: z.literal(true).optional(), + handoverRecorded: z.literal(true).optional(), + handedOverAt: z.number().optional() }) export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody { diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a13f3b1f676e..233acf3bbbe8 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -360,6 +360,13 @@ export type AgentJournalSubmission = { /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ recovered?: true + /** The host accepted this send to hand over later; absent on sends dispatched as they were + * recorded (older hosts). With no `handedOverAt` yet, a pending one is still queued. */ + handoverRecorded?: true + /** When the host handed it to the provider (its `dispatch{pending}` row). */ + handedOverAt?: number + /** Host-only: the submission row's sequence, which tells which host process accepted it. */ + acceptedSequence?: number } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index fe439c20b08e..f0cae00c1b34 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -91,6 +91,9 @@ export function admitAgentSessionMutation(input: { /** Decision from the durable ledger, evaluated under `hostFingerprint`. */ ledger: AgentSessionOperationDecision lease: AgentSessionLease + /** A write to the conversation, not to the provider child: a send is accepted and a Stop + * withdraws queued messages whoever owns the child, so the lease does not admit them. */ + conversationWrite?: true }): AgentSessionMutationAdmission { const { envelope, lease, ledger } = input const mismatch = agentSessionFingerprintConflict(envelope, input.hostFingerprint) @@ -109,6 +112,9 @@ export function admitAgentSessionMutation(input: { if (ledger.decision === 'replay') { return { decision: 'replay', row: ledger.row } } + if (input.conversationWrite) { + return { decision: 'admit', row: ledger.row } + } const leaseRefusal = refuseUnlessWriterAdmitted(lease) if (leaseRefusal) { return { decision: 'refused', refusal: leaseRefusal } diff --git a/src/shared/agent-session-queued-submission.ts b/src/shared/agent-session-queued-submission.ts new file mode 100644 index 000000000000..869941d2e120 --- /dev/null +++ b/src/shared/agent-session-queued-submission.ts @@ -0,0 +1,11 @@ +import type { AgentJournalSubmission } from './agent-session-journal-types' + +/** Accepted by the host and not yet handed to the provider: provably unwritten, so every way out + * of this state is delivery or a rejection, never doubt. */ +export function isQueuedAgentJournalSubmission(submission: AgentJournalSubmission): boolean { + return ( + submission.handoverRecorded === true && + submission.dispatchState === 'pending' && + submission.handedOverAt === undefined + ) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 784d7536e023..550e6217e235 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -165,6 +165,11 @@ export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.struct // clients skip the host's bounded best-effort settlement observation. export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = 'agent-session.pending-send-result.v1' as const +// Why: a send is now answered once the host accepts it, before any agent has it. A client without +// this cannot show a message rejected after that answer, so the host holds its reply until the +// message is handed over or rejected. +export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = + 'agent-session.accepted-send.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = @@ -296,6 +301,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 14e197325d8f..a88dad5b16eb 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -29,6 +29,12 @@ export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch que /** The provider confirmed a queued frame was withdrawn before execution. */ export const DISPATCH_REJECTED_CANCELLED = 'provider_cancelled_before_start' +/** Accepted by a host process that ended before handing it to any provider. */ +export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery' + +/** Accepted, then the provider was closed before the message was handed to it. */ +export const DISPATCH_REJECTED_PROVIDER_CLOSED = 'provider_closed_before_delivery' + export function dispatchWriteFailureReason(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) return `${DISPATCH_REJECTED_WRITE_FAILED}: ${detail}` @@ -54,6 +60,8 @@ export function dispatchRejectionReasonIsInternal(reason: string | null | undefi dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL || reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL || - reason === DISPATCH_REJECTED_CANCELLED + reason === DISPATCH_REJECTED_CANCELLED || + reason === DISPATCH_REJECTED_HOST_RESTARTED || + reason === DISPATCH_REJECTED_PROVIDER_CLOSED ) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index a336dea6fedd..f39b07f6627c 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -7,7 +7,13 @@ import type { import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' -export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed' +/** `rejected`: the host settled the send as not delivered. The drain never sends it again on its + * own and nothing queues behind it; only the user's Retry does. */ +export type StructuredAgentSessionOutboxState = + | 'queued' + | 'dispatching' + | 'unconfirmed' + | 'rejected' export type StructuredAgentSessionOutboxEntry = { clientMessageId: string @@ -88,10 +94,12 @@ export function requeueStructuredAgentSessionSendRefusal( ) { return { ...entry, state: 'queued' } } + // Only here is the refusal proof the message never landed: an earlier attempt under this id, or + // one whose delivery was in doubt, may have, so those stay queued behind the block. return { ...entry, clientMessageId: createOperationId(), - state: 'queued', + state: 'rejected', lastAttemptAt: null, retryAfterUnknownSubmittedAt: null } @@ -116,6 +124,11 @@ export function reconcileStructuredAgentSessionOutbox( if (submission?.dispatchState === 'pending') { return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] } + // Accepted, then not delivered — the agent never started, or its start was refused. The text + // stays here for the user's Retry, and nothing queues behind it. + if (submission?.dispatchState === 'rejected' && entry.state === 'dispatching') { + return [{ ...entry, state: 'rejected' as const }] + } if ( submission?.dispatchState === 'unknown' && entry.retryAfterUnknownSubmittedAt !== -1 && @@ -147,6 +160,10 @@ export function admitStructuredAgentSessionOutboxEntry( blockedClientMessageId: string | null ): StructuredAgentSessionOutboxAdmission { for (const entry of entries) { + // It can no longer land, so nothing it could be reordered around; it waits for Retry. + if (entry.state === 'rejected') { + continue + } if (entry.state === 'unconfirmed' || entry.clientMessageId === blockedClientMessageId) { return { state: 'blocked', entry } } @@ -176,7 +193,7 @@ export function parseStructuredAgentSessionOutboxEntry( !Array.isArray(body.blocks) || !Array.isArray(entry.previewUris) || !entry.previewUris.every((uri) => typeof uri === 'string') || - !['queued', 'dispatching', 'unconfirmed'].includes(entry.state ?? '') + !['queued', 'dispatching', 'unconfirmed', 'rejected'].includes(entry.state ?? '') ) { return null } diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 16765a024714..541688746361 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -7,6 +7,7 @@ // the refs, the React state and the storage write, and nothing else decides an // entry's state. +import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { dispatchRejectionReasonIsInternal, @@ -89,6 +90,21 @@ function refusedRedelivery( * Exported because a client without an outbox needs the same copy: the rule about * which reasons a person may read is a property of the reason, not of the queue. */ +/** A message the host accepted and then did not deliver: the chat says why and offers Retry. */ +export function rejectedDispatchingSubmission( + entries: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): AgentJournalSubmission | undefined { + return submissions.find( + (submission) => + submission.dispatchState === 'rejected' && + entries.some( + (entry) => + entry.clientMessageId === submission.clientMessageId && entry.state === 'dispatching' + ) + ) +} + export function structuredAgentSessionRejectionNotice(reason: string | null): string { if (reason === null) { return 'Message was not sent.' @@ -124,12 +140,17 @@ export function disposeStructuredAgentSessionSendResult( ) : candidate ) + const refused = entries[refusedIndex] return { entries, error: result.refusal.message, // Read back by index rather than from the input: a refusal can rotate the id, and the // refused entry is not always the head now that an admitted one no longer holds the queue. - blockedClientMessageId: entries[refusedIndex]?.clientMessageId ?? null, + // A rejected one holds nothing: it can no longer land, and it keeps its own Retry. + blockedClientMessageId: + !refused || refused.state === 'rejected' + ? input.blockedClientMessageId + : refused.clientMessageId, retryWithFreshClientMessageId: null } } @@ -152,9 +173,9 @@ export function disposeStructuredAgentSessionSendResult( } if (submission.dispatchState === 'rejected') { return { - entries: replaceEntryState(input, 'queued'), + entries: replaceEntryState(input, 'rejected'), error: structuredAgentSessionRejectionNotice(submission.reason), - blockedClientMessageId: input.entry.clientMessageId, + blockedClientMessageId: input.blockedClientMessageId, retryWithFreshClientMessageId: input.entry.clientMessageId } } diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts index 36c70a357cb7..002db36cb57d 100644 --- a/src/shared/structured-agent-session-unanswered-dispatch.ts +++ b/src/shared/structured-agent-session-unanswered-dispatch.ts @@ -1,4 +1,5 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' /** One send the provider has neither opened a turn for nor refused; the rule is explained on * `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */ @@ -6,6 +7,10 @@ export function isUnansweredStructuredAgentSessionDispatch( submission: AgentJournalSubmission, currentFence?: number | null ): boolean { + if (isQueuedAgentJournalSubmission(submission)) { + // Accepted and still owed to whichever child the host starts next, whatever the fence. + return true + } return ( (currentFence == null || submission.fence >= currentFence) && (submission.dispatchState === 'pending' || 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 4d18e81ced6d..9a048d099683 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 @@ -22,6 +22,7 @@ import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, @@ -582,6 +583,10 @@ describe('cross-version structured agent sessions', () => { let store: AgentSessionRecordStore let runtime: unknown + /** Holds a provider start open, so a reply's timing can be read against it. */ + let startGate: Promise = Promise.resolve() + let starts = 0 + /** Phase 2 owns provider processes; the adapter is the only stub here. */ function adapter(): StructuredAgentSessionAdapter { return { @@ -589,23 +594,27 @@ describe('cross-version structured agent sessions', () => { // `supportsLocation`, which this fake also lacks, so the client-supplied-location gate // refused for the fake's silence rather than for the location. supportsCreate: () => true, - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - // A restarted host re-proves the thread it inherited; only the first - // owner of a session may claim to have created it. - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW + acquire: async ({ fence }) => { + starts += 1 + await startGate + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } } - }), + }, dispatch: async () => ({ state: 'accepted', providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } @@ -660,14 +669,18 @@ describe('cross-version structured agent sessions', () => { return reattached } - async function call(method: string, params: unknown): Promise { + async function call( + method: string, + params: unknown, + clientCapabilities: readonly string[] = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + ): Promise { return callBuild( current, method, params, { clientKind: 'runtime', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + clientCapabilities, clientId: 'paired-device-1', connectionId: 'connection-1' }, @@ -686,6 +699,7 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { resetOperationIds() + startGate = Promise.resolve() root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) runtime = runtimeStub() await bootHost('a') @@ -739,5 +753,58 @@ describe('cross-version structured agent sessions', () => { fence: reattached.fence }) }) + + // A released client answers a send's `pending` as delivered-or-refused; it has no way to show + // a rejection that arrives after it. So it is answered once the message is handed over, while + // a client that advertises accepted sends is answered at acceptance, start or no start (W9). + it('holds the send reply of a released client until the handover, and answers a current one at once', async () => { + const released = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY + ] + expect(baseline.capabilities).not.toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + const created = await answer('agentSession.create', createIntentParams()) + await bootHost('b') + let open = (): void => undefined + startGate = new Promise((resolve) => (open = resolve)) + + let answered = false + const releasedReply = call( + 'agentSession.send', + sendParams('released', created.fence), + released + ).finally(() => (answered = true)) + // The start that delivers it is under way, and the reply still waits for it. + const before = starts + await vi.waitFor(() => expect(starts).toBeGreaterThan(before)) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(answered).toBe(false) + open() + const [reply] = await releasedReply + // Handed over, whatever the provider has said since. + expect(reply).toMatchObject({ + ok: true, + result: { value: { submission: { handedOverAt: expect.any(Number) } } } + }) + + const restarted = await bootHost('c') + startGate = new Promise((resolve) => (open = resolve)) + const currentReply = await call('agentSession.send', sendParams('current', created.fence), [ + ...released, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ]) + expect(currentReply[0]).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'pending', handoverRecorded: true } } } + }) + open() + await vi.waitFor(() => + expect( + restarted + .journalSnapshot(SESSION) + .submissions.every((row) => row.dispatchState !== 'pending' || row.handedOverAt) + ).toBe(true) + ) + }) }) }) From 82f9bfd7b31d8ab73540464efc04e8abaa602f21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:17:17 -0700 Subject: [PATCH 028/101] fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. --- ...-agent-session-accept-then-deliver.test.ts | 26 +++++++++++++++++++ ...gent-session-dead-generation-settlement.ts | 23 +++++++++------- .../structured-agent-session-host-lifetime.ts | 1 + ...red-agent-session-settlement-retry.test.ts | 25 ++++++++++++++++++ ...tructured-agent-session-unexpected-exit.ts | 5 ++++ src/shared/agent-session-queued-submission.ts | 4 ++- 6 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index efba24208d15..dc69482a25bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -425,6 +425,32 @@ describe('what an earlier host process left behind', () => { }) }) +describe('a child that exits before its message is handed over', () => { + it('rejects the message with the exit reason instead of starting another child (W24)', async () => { + // Each child the loop starts dies between its start step and its handover step. + const awaitStarted = vi.fn(async (sessionId: string) => { + await host.handleAdapterEvent({ + type: 'ended', + sessionId, + fence: store.getRecord(sessionId)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'codex app-server crashed', + cause: 'unexpected-exit' + }) + }) + adapterExtras = { awaitStarted } + await host.close(SESSION) + await startHost() + + const id = await accept('hello') + + await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + expect(submission(id)?.reason).toContain('codex app-server crashed') + expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).not.toHaveBeenCalled() + }) +}) + describe('Stop withdraws what is queued', () => { it('withdraws a crash leftover the open could not settle, ahead of any delivery step (W17a)', async () => { await writeAsEarlierProcess(async (journal, fence) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index aa7d8ea36f36..0675d8730a6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -4,7 +4,7 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { DISPATCH_REJECTED_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { @@ -154,26 +154,29 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { unexpectedExitReason?: string /** The provider never finished starting; the outcome says so instead of naming a response. */ exitedDuringStartup?: boolean + /** Why messages queued for delivery are rejected: given only when the child they were queued + * for is the one that ended. A retry for an earlier generation leaves them to the next child. */ + queuedRejection?: string onError?: (sessionId: string, error: unknown) => void }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork - if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { + // Settled even with nothing else in flight: left queued, delivery would start another child + // for them, and one that dies the same way would start another. + const rejectsQueued = + input.queuedRejection !== undefined && + input.journal.submissions?.().some(isQueuedAgentJournalSubmission) === true + if (!showUnexpectedExitOutcome && !hasUnfinishedWork && !rejectsQueued) { return true } // A queued message was never handed to this child, so it is provably unwritten. A child // that never proved its start accepted nothing either — input is written only after it // initializes — so every send it left unanswered is rejected with the child's own diagnostic. // A proven child's handed-over sends stay in doubt. - await input.journal.rejectQueuedSubmissions( - input.fence, - input.exitedDuringStartup - ? providerStartupFailureRejection(input.unexpectedExitReason) - : input.unexpectedExitReason === undefined - ? DISPATCH_REJECTED_PROVIDER_CLOSED - : providerExitBeforeDeliveryRejection(input.unexpectedExitReason) - ) + if (input.queuedRejection !== undefined) { + await input.journal.rejectQueuedSubmissions(input.fence, input.queuedRejection) + } await (input.exitedDuringStartup ? input.journal.rejectPendingSubmissions( input.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 39f0e4415df7..e3b284acb5be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -115,6 +115,7 @@ export async function evictHeldStructuredAgentSession( pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, + queuedRejection: DISPATCH_REJECTED_PROVIDER_CLOSED, onError: (id, error) => { settlementError = error context.deps.onEventSinkError?.({ sessionId: id, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts index c0d29f6b9efe..976e9417628a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts @@ -119,6 +119,31 @@ describe('pending settlement retry', () => { expect(record.lease.settlementRetryRequired).toBeUndefined() }) + it('leaves a message queued for the next child to that child', async () => { + await seedRunningTurn() + await journal.appendSubmission({ + clientMessageId: 'queued', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: FENCE, + handoverRecorded: true + }) + + await expect( + retry(`restart-eviction:${SESSION}:${FENCE}`, { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 1_500 + }) + ).resolves.toBe(true) + + // The retry settles the generation that died; the message was never that child's. + expect(journal.submissions()).toEqual([ + expect.objectContaining({ clientMessageId: 'queued', dispatchState: 'pending' }) + ]) + expect(journal.submissions()[0]?.handedOverAt).toBeUndefined() + }) + it('writes no status row for an identity mismatch either', async () => { await seedRunningTurn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 5363b8588e74..7e7148383111 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -11,6 +11,8 @@ import type { StructuredAgentSessionSinkBarrier } from './structured-agent-sessi import { captureUnfinishedStructuredAgentSessionWork, MAX_UNEXPECTED_EXIT_REASON_CHARS, + providerExitBeforeDeliveryRejection, + providerStartupFailureRejection, settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted @@ -209,6 +211,9 @@ async function retryUnexpectedExitSettlement(input: { showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, unexpectedExitReason: input.event.reason, exitedDuringStartup: input.exitedDuringStartup, + queuedRejection: input.exitedDuringStartup + ? providerStartupFailureRejection(input.event.reason) + : providerExitBeforeDeliveryRejection(input.event.reason), onError: input.context.onBarrierError }) } diff --git a/src/shared/agent-session-queued-submission.ts b/src/shared/agent-session-queued-submission.ts index 869941d2e120..6295897fd54e 100644 --- a/src/shared/agent-session-queued-submission.ts +++ b/src/shared/agent-session-queued-submission.ts @@ -2,7 +2,9 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' /** Accepted by the host and not yet handed to the provider: provably unwritten, so every way out * of this state is delivery or a rejection, never doubt. */ -export function isQueuedAgentJournalSubmission(submission: AgentJournalSubmission): boolean { +export function isQueuedAgentJournalSubmission( + submission: Pick +): boolean { return ( submission.handoverRecorded === true && submission.dispatchState === 'pending' && From f3e52d206b3c529bb3e151cde5b42fa60591e211 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:17:28 -0700 Subject: [PATCH 029/101] fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. --- ...tured-agent-session-adopted-import.test.ts | 44 +++++++++++++++++++ ...structured-agent-session-adopted-import.ts | 9 ++-- .../structured-agent-session-attach-flow.ts | 8 +++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 0248799980a7..a8c19781d71a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -15,6 +15,7 @@ import { performAttach, type AttachFlowInput } from './structured-agent-session- import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import * as legacyImport from '../agent-session-journal/journal-legacy-import' +import { StructuredAgentSessionHost } from './structured-agent-session-host' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -225,6 +226,49 @@ describe('adopting a provider conversation on create', () => { expect(close).toHaveBeenCalledTimes(1) }) + it('leaves the conversation writable when the import fails after acquiring', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-host-failure-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'valid source') + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( + new Error('disk write failed') + ) + const attached = await host + .attach({ callerKey: 'client-1' }, attachParams(transcriptPath)) + .catch(() => null) + expect(attached?.ok).not.toBe(true) + + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + const sent = await host.send( + { callerKey: 'client-1' }, + { + envelope: { + sessionId: SESSION, + clientOperationId: `${NOW}-${'2'.padStart(32, '0')}`, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + // The failed attach kept the conversation's own journal open, so the send is recorded. + expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + await host.flushAllStreamedEvents() + }) + it('prepares a valid source once before acquisition and imports those exact items', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-once-')) const transcriptPath = join(root, 'rollout.jsonl') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 459d21b1f3b7..63d00ef53981 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -60,13 +60,16 @@ export async function importAdoptedTranscript( params: AgentSessionAttachParams, attached: AttachedJournal, record: AgentSessionRecord, - prepared: JournalReplacementItem[] | null + prepared: JournalReplacementItem[] | null, + ownsJournal: boolean ): Promise { try { await applyAdoptedTranscript(params, attached, record, prepared) } catch (error) { - // Publication has not taken ownership of this provisional journal yet. - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) + // A journal the attach opened for itself has no other owner; the conversation's stays open. + if (ownsJournal) { + await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) + } throw error } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 83555d637294..3498b6f5d88f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -212,7 +212,13 @@ export async function performAttach( ...(input.openConversation ? { openConversation: input.openConversation } : {}), providerHistoryWindow }) - await importAdoptedTranscript(params, attached, record, preparedTranscript.items) + await importAdoptedTranscript( + params, + attached, + record, + preparedTranscript.items, + input.openConversation === undefined + ) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) await store.recordOperationOutcome({ callerKey: input.callerKey, From 29a340b0b483e752d863b40746ddbdfbaea5fd17 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:17:30 -0700 Subject: [PATCH 030/101] perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. --- .../agent-session-journal-recovery.test.ts | 25 +++++++++++++++++-- .../agent-session-journal-recovery.ts | 4 ++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 214c889b5c9d..1c60029c7d32 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -6,14 +6,14 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { openJournalDatabase } from '../agent-session-journal/journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' -import { loadJournal } from '../agent-session-journal/journal-open' +import { loadJournal, replayJournal } from '../agent-session-journal/journal-open' import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { readJournalEpochRows } from '../agent-session-journal/journal-row-table' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' @@ -24,6 +24,12 @@ import { recoveryJournalDir } from './agent-session-journal-recovery' +// Only the store's own replay goes through the mock; the probe's, inside the same module, does not. +vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { + const actual = await importOriginal<{ replayJournal: typeof replayJournal }>() + return { ...actual, replayJournal: vi.fn(actual.replayJournal) } +}) + const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' const IDENTITY: AgentSessionJournalIdentity = { @@ -160,6 +166,21 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(opened.snapshot().items).toHaveLength(2) }) + it('reads the journal once: the probe is the open', async () => { + await seedJournal(2) + vi.mocked(replayJournal).mockClear() + const opened = journals.track( + await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }).then((result) => result.journal) + ) + expect(opened.snapshot().items).toHaveLength(2) + expect(replayJournal).not.toHaveBeenCalled() + }) + it('rebuilds a holed journal in place on a fresh epoch', async () => { await seedJournal(3) await deleteRow(3) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 6e771a318092..29d324be9718 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -67,7 +67,9 @@ export async function openAgentSessionJournalWithRecovery(input: { } const journal = await openAgentSessionJournal({ identity: input.identity, - journalDir: input.journalDir + journalDir: input.journalDir, + // The probe is this open's replay; omitted, not `null`, when there was nothing to load. + ...(probe ? { loaded: probe } : {}) }) if (!probe?.corrupt) { return { journal, recovery: null } From c7a652fe2925975061f5db168d2c4046e520af0c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:27:53 -0700 Subject: [PATCH 031/101] fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. --- ...-agent-session-accept-then-deliver.test.ts | 24 ++++++++++++++ ...ured-agent-session-attach-orchestration.ts | 33 +++++++++++++++---- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index dc69482a25bc..8fc325a57e4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -389,6 +389,30 @@ describe('a start the chat needed and did not get', () => { }) }) +describe('an attach that fails after indexing its child', () => { + it('leaves no child behind, so the next send starts one and is delivered', async () => { + await host.close(SESSION) + const first = await accept('hello') + // The attach's own success record is the step after `onAttached` indexed the child. + const record = vi.spyOn(store, 'recordOperationOutcome') + record.mockImplementation(async (input) => { + if (input.operationId !== first && input.outcome.status === 'succeeded') { + record.mockRestore() + throw new Error('record store write failed') + } + return AgentSessionRecordStore.prototype.recordOperationOutcome.call(store, input) + }) + await eventually(() => expect(submission(first)?.dispatchState).toBe('rejected')) + const acquiresBefore = acquire.mock.calls.length + + const next = await accept('after the failure') + + await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + expect(acquire).toHaveBeenCalledTimes(acquiresBefore + 1) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) + }) +}) + describe('what an earlier host process left behind', () => { it('rejects a message it accepted and never handed over, as not sent (W4′b)', async () => { await writeAsEarlierProcess(async (journal, fence) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 26cf103d3621..6aeb6bc46d59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -24,6 +24,7 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -132,6 +133,8 @@ async function runAttach( // attach makes it the session's; any other exit closes it with whatever the child queued. const attemptSink = context.runtimeState.mintEventSink(sessionId) let attemptSinkAdopted = false + let indexed: StructuredAgentSessionHostSession | null = null + let attachedOk = false const attached = stampFailedCreateOwnerVerdict( context.deps.store, callerKey, @@ -192,7 +195,7 @@ async function runAttach( ) context.runtimeState.adoptEventSink(sessionId, eventSink) attemptSinkAdopted = eventSink === attemptSink - context.sessions.set(sessionId, { + indexed = { journal: attached.journal, params, fence, @@ -202,7 +205,8 @@ async function runAttach( ? providerChildPhase : (previous?.providerChildPhase ?? 'ready'), acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null - }) + } + context.sessions.set(sessionId, indexed) await recoverStructuredRewind( context.deps.store, sessionId, @@ -218,12 +222,27 @@ async function runAttach( context.subscribers.publish(sessionId, attached.journal) } } - }).finally(() => { - if (!attemptSinkAdopted) { - attemptSink.close() - followRecordFence(context, sessionId) - } }) + .then((result) => { + attachedOk = result.ok + return result + }) + .finally(() => { + const releasedIndexedChild = + !attachedOk && indexed !== null && context.sessions.get(sessionId) === indexed + if (releasedIndexedChild && indexed) { + // The failure path released the child this attempt indexed; the conversation stays. + indexed.hasProviderChild = false + indexed.providerChildPhase = 'ready' + context.runtimeState.currentEventSink(sessionId)?.close() + context.runtimeState.discardEventSink(sessionId) + context.publishStatus?.(sessionId) + } + if (!attemptSinkAdopted || releasedIndexedChild) { + attemptSink.close() + followRecordFence(context, sessionId) + } + }) ) return attached } From 34d0bb0d34ec9eb6862238df001d94b5562282ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:37:04 -0700 Subject: [PATCH 032/101] fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. --- ...nt-session-outbox-rejection-cause.test.tsx | 75 +++++++++++++++++++ .../use-structured-agent-session-outbox.ts | 12 +-- ...ructured-agent-session-send-disposition.ts | 49 ++++++++---- 3 files changed, 115 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index c1945503d270..815c1a2c4031 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' const mocks = vi.hoisted(() => ({ call: vi.fn() @@ -174,6 +175,80 @@ describe('a send the host rejected because the agent never started', () => { expect(retried.envelope.clientOperationId).not.toBe(id) expect(retried.body.blocks[0]?.text).toBe('hello') }) + + it('says nothing when a Stop withdrew the message', async () => { + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => pendingResultFor(params.envelope.clientOperationId) + ) + const target = { kind: 'local' } as const + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: NO_SUBMISSIONS } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + const id = result.current.outbox[0]!.clientMessageId + + rerender({ + submissions: [ + { + ...pendingResultFor(id).value.submission, + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + } + ] + }) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(result.current.error).toBeNull() + }) + + it('keeps the rejection when the journal settles the message before the send answers', async () => { + const reason = "Codex couldn't restart: spawn codex ENOENT." + let answer: (value: unknown) => void = () => undefined + mocks.call.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const target = { kind: 'local' } as const + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: NO_SUBMISSIONS } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + const id = result.current.outbox[0]!.clientMessageId + + // A start refused at once: the rejection frame lands before the send's own `pending` answer. + rerender({ + submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] + }) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + await act(async () => answer(pendingResultFor(id))) + + expect(result.current.outbox[0]?.state).toBe('rejected') + expect(result.current.error).toBe(reason) + }) }) const NO_SUBMISSIONS: AgentJournalSubmission[] = [] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 9801caa80ad0..c8e2f87420c5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -8,8 +8,8 @@ import { type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { - rejectedDispatchingSubmission, - structuredAgentSessionRejectionNotice, + journalAnswersInFlightSend, + reconciledRejectionNotice, type StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' @@ -106,7 +106,7 @@ export function useStructuredAgentSessionOutbox(args: { .map((submission) => submission.clientMessageId) ) const next = reconcileStructuredAgentSessionOutbox(current, submissions) - const admittedInFlight = inFlightIdRef.current !== null && hostOwns.has(inFlightIdRef.current) + const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current) if ( admittedInFlight || next.some((entry, index) => entry !== current[index]) || @@ -124,9 +124,9 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGenerationRef.current += 1 inFlightIdRef.current = null } - const rejected = rejectedDispatchingSubmission(current, submissions) - if (rejected) { - setError(structuredAgentSessionRejectionNotice(rejected.reason)) + const rejection = reconciledRejectionNotice(current, next, submissions) + if (rejection) { + setError(rejection) } else if (blockedIdRef.current !== null && hostOwns.has(blockedIdRef.current)) { blockedIdRef.current = null setError(null) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 541688746361..72368dae921f 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -73,6 +73,40 @@ function refusedRedelivery( ) } +/** Whether the journal already answers a send still in flight, so its own reply adds nothing: the + * host holds the message, or rejected it — a later `pending` reply must not undo that. */ +export function journalAnswersInFlightSend( + submissions: readonly AgentJournalSubmission[], + clientMessageId: string | null +): boolean { + return submissions.some( + (submission) => + submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown' + ) +} + +/** What to say for a message the reconcile just settled as accepted and then not delivered, which + * keeps its Retry; null when there is none. A Stop's withdrawal is dropped there, so says nothing. */ +export function reconciledRejectionNotice( + previous: readonly StructuredAgentSessionOutboxEntry[], + reconciled: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): string | null { + const was = ( + entries: readonly StructuredAgentSessionOutboxEntry[], + clientMessageId: string, + state: StructuredAgentSessionOutboxEntry['state'] + ): boolean => + entries.some((entry) => entry.clientMessageId === clientMessageId && entry.state === state) + const rejected = submissions.find( + (submission) => + submission.dispatchState === 'rejected' && + was(previous, submission.clientMessageId, 'dispatching') && + was(reconciled, submission.clientMessageId, 'rejected') + ) + return rejected ? structuredAgentSessionRejectionNotice(rejected.reason) : null +} + /** * What to put on screen for a rejection. * @@ -90,21 +124,6 @@ function refusedRedelivery( * Exported because a client without an outbox needs the same copy: the rule about * which reasons a person may read is a property of the reason, not of the queue. */ -/** A message the host accepted and then did not deliver: the chat says why and offers Retry. */ -export function rejectedDispatchingSubmission( - entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): AgentJournalSubmission | undefined { - return submissions.find( - (submission) => - submission.dispatchState === 'rejected' && - entries.some( - (entry) => - entry.clientMessageId === submission.clientMessageId && entry.state === 'dispatching' - ) - ) -} - export function structuredAgentSessionRejectionNotice(reason: string | null): string { if (reason === null) { return 'Message was not sent.' From 46b6472ab8766143c79123bb9052c83c06dc8059 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:41:15 -0700 Subject: [PATCH 033/101] fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. --- ...stration-structured-worker-session.test.ts | 28 +++--- ...orchestration-structured-worker-session.ts | 12 +-- .../orchestration-worker-mode-opacity.test.ts | 1 + ...ration-worker-start-mode-selection.test.ts | 2 +- .../deliver-worker-dispatch-preamble.ts | 40 ++++++--- .../worker-start-readiness-settlement.test.ts | 88 +++++++++++++++++++ .../worker-start-readiness-settlement.ts | 17 ++-- .../worker/worker-start-turn-observation.ts | 2 + 8 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts 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 7f8fd0fea0dd..c8ebb9eab63e 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 @@ -244,7 +244,7 @@ describe('structured worker dispatch preamble', () => { it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) - ).resolves.toBeUndefined() + ).resolves.toBe('accepted') }) it('waits for an accepted preamble to be delivered, and reports that delivery (W10)', async () => { @@ -255,7 +255,15 @@ describe('structured worker dispatch preamble', () => { { dispatchState: 'accepted', reason: null } ) ) - ).resolves.toBeUndefined() + ).resolves.toBe('accepted') + }) + + it('reports a preamble still held for an agent that outlasted the wait, without failing the start (W10)', async () => { + // Held, not lost: the host delivers it when the agent starts. Throwing here tore the worker + // down, which rejected the preamble the start was about to deliver. + await expect( + send(hostWithSubmission({ dispatchState: 'pending', reason: null })) + ).resolves.toBe('pending') }) it('never claims delivery for a submission the provider never acknowledged', async () => { @@ -263,15 +271,13 @@ describe('structured worker dispatch preamble', () => { // into `unknown`, and `performSend` still returns ok. Reporting that as `dispatch_input: // accepted` marks the worker ready with no task, and the coordinator blocks in // `check --wait --types worker_done` until it times out. - for (const dispatchState of ['unknown', 'pending'] as const) { - const error = await send( - hostWithSubmission({ dispatchState, reason: 'provider child exited' }) - ).catch((thrown: unknown) => thrown) - expect((error as { code?: string }).code).toBe('operation_unknown') - // The wiring, not just the throw: this is the code that makes the start receipt - // `outcome_unknown` with the worker-show / worker-abandon recovery commands. - expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(true) - } + const error = await send( + hostWithSubmission({ dispatchState: 'unknown', reason: 'provider child exited' }) + ).catch((thrown: unknown) => thrown) + expect((error as { code?: string }).code).toBe('operation_unknown') + // The wiring, not just the throw: this is the code that makes the start receipt + // `outcome_unknown` with the worker-show / worker-abandon recovery commands. + expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(true) }) it('keeps a rejected preamble a proven failure under a code of its own', async () => { 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 a889ffb58f1b..3ac972138697 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -214,13 +214,14 @@ export async function discardStructuredWorkerSession( retireSettledStructuredWorkerTab(sessionId, runtime) } -/** Delivers the dispatch preamble as the worker's first turn. */ +/** Delivers the dispatch preamble as the worker's first turn. `pending`: the worker's agent had + * not taken it within the wait; the host still holds it for that agent, and never re-sends it. */ export async function sendStructuredWorkerPreamble(args: { host: StructuredAgentSessionHost sessionId: string dispatchId: string preamble: string -}): Promise { +}): Promise<'accepted' | 'pending'> { const body: AgentJournalMessageItem = { kind: 'message', role: 'user', @@ -245,8 +246,7 @@ export async function sendStructuredWorkerPreamble(args: { if (!result.ok) { throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`) } - // Accepted is not delivered: the worker's agent may still be starting. Never re-sent; a wait - // that runs out is reported as unknown below and settles when the submission does. + // Accepted is not delivered: the worker's agent may still be starting. const submission = result.value.submission.dispatchState === 'pending' ? (( @@ -257,8 +257,8 @@ export async function sendStructuredWorkerPreamble(args: { .catch(() => undefined) )?.value.submission ?? result.value.submission) : result.value.submission - if (submission.dispatchState === 'accepted') { - return + if (submission.dispatchState === 'accepted' || submission.dispatchState === 'pending') { + return submission.dispatchState } if (submission.dispatchState === 'rejected') { // A rejection is a verdict, not a mystery: the preamble provably did not happen. diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index aa8c7c58574f..48ffe07d5d09 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -42,6 +42,7 @@ vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ( ...(await importOriginal>()), sendStructuredWorkerPreamble: async (args: { preamble: string }) => { structuredPreambles.push(args.preamble) + return 'accepted' }, releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index 52cf3579016c..a2beea9ea05c 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -30,7 +30,7 @@ vi.mock('./orchestration/federation/federated-worker-start', () => ({ })) vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ ...(await importOriginal>()), - sendStructuredWorkerPreamble: async () => {}, + sendStructuredWorkerPreamble: async () => 'accepted', releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} })) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index ecb3270874e4..bc2b9601cebc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -5,6 +5,7 @@ import { dispatchPreambleSendOptions } from '../../../../orchestration/preamble' import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session' +import type { WorkerTurnStartObservation } from './worker-start-turn-observation' import type { createStructuredWorkerSessionForWorktree } from './worker-topology' type StructuredSession = Awaited> | null @@ -14,7 +15,8 @@ type StructuredSession = Awaited { +}): Promise<{ + prompt?: RuntimeTerminalSend['prompt'] + structuredTurnStart?: WorkerTurnStartObservation +}> { const { runtime, structuredSession, terminalHandle } = args const preamble = buildDispatchPreamble({ // Depth only. A worker is taught the same verbs whichever mode it runs in, so this must not @@ -45,19 +50,32 @@ export async function deliverWorkerDispatchPreamble(args: { cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) }) if (structuredSession) { - await sendStructuredWorkerPreamble({ + const delivery = await sendStructuredWorkerPreamble({ host: structuredSession.host, sessionId: structuredSession.identity.sessionId, dispatchId: args.dispatchId, preamble }) - return undefined + return { + structuredTurnStart: + delivery === 'accepted' + ? { verdict: 'observed' } + : { + verdict: 'unobserved', + reason: + 'The dispatch preamble was accepted, but the agent had not started to take it. It ' + + 'is delivered when the agent starts; if the worker then reports, this Dispatch ' + + 'settles normally.' + } + } + } + return { + prompt: ( + await runtime.sendTerminalAgentPrompt( + terminalHandle, + preamble, + dispatchPreambleSendOptions(args.requestId) + ) + ).prompt } - return ( - await runtime.sendTerminalAgentPrompt( - terminalHandle, - preamble, - dispatchPreambleSendOptions(args.requestId) - ) - ).prompt } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts new file mode 100644 index 000000000000..13b31f1ad574 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../../../../orchestration/preamble', () => ({ buildDispatchPreamble: () => 'preamble' })) +vi.mock('./worker-topology', async (importOriginal) => ({ + ...(await importOriginal>()), + monitorWorkerSetup: () => {} +})) + +const { deliverAndSettleWorkerStartReadiness } = await import('./worker-start-readiness-settlement') + +function settle(delivered: 'accepted' | undefined) { + const db = { + getWorkerDispatch: () => ({ state: 'starting' }), + markWorkerStartUnknown: vi.fn(() => ({ + stage: 'turn_start_unobserved', + residual_resources: '[]' + })), + markWorkerDispatchReady: vi.fn(() => ({ state: 'ready', stage: 'ready' })) + } + const host = { + deps: { store: { getRecord: () => ({ lease: { runtimeFence: 1 } }) } }, + send: async () => ({ + ok: true, + value: { clientMessageId: 'c1', submission: { dispatchState: 'pending', reason: null } } + }), + // undefined: the worker's agent was still starting when the wait ran out. + waitForSendSettlement: async () => + delivered + ? { value: { clientMessageId: 'c1', submission: { dispatchState: delivered } } } + : undefined + } + const receipt = deliverAndSettleWorkerStartReadiness({ + runtime: { + getNestedWorkerMaxDepth: () => 3, + getTerminalOrchestrationCliCommand: () => 'orca' + }, + db, + run: { id: 'run_1' }, + task: { id: 't1', spec: 'do the thing' }, + dispatchId: 'd1', + dispatchDepth: 0, + structuredSession: { host, identity: { sessionId: 's1' } }, + terminalHandle: 'structured_worker_1', + coordinatorHandle: 'term_c', + dispatchCapability: 'capability', + devMode: undefined, + requestId: 'r1', + agent: 'claude', + setupReceipt: {}, + launchReceipt: {}, + mode: {}, + timeoutMs: 60_000, + effects: [], + terminalRevealWarning: undefined, + onStage: () => {} + } as never) + return { db, receipt } +} + +describe('a structured worker whose agent outlasts the preamble wait', () => { + it('parks as start-unknown instead of failing the start, and never names a screen to read', async () => { + const { db, receipt } = settle(undefined) + + // Resolving, not throwing, is what keeps the worker's session: a throw tears it down. + await expect(receipt).resolves.toMatchObject({ + state: 'outcome_unknown', + turnStart: 'unobserved', + nextCommands: [ + 'orca orchestration worker-show --dispatch d1 --json', + 'orca orchestration worker-abandon --dispatch d1 --json' + ] + }) + expect(db.markWorkerStartUnknown).toHaveBeenCalledWith( + 'd1', + 'turn_start_unobserved', + expect.stringContaining('delivered when the agent starts'), + expect.anything() + ) + expect(db.markWorkerDispatchReady).not.toHaveBeenCalled() + }) + + it('is ready once the agent took the preamble within the wait', async () => { + const { db, receipt } = settle('accepted') + + await expect(receipt).resolves.toMatchObject({ state: 'ready', turnStart: 'observed' }) + expect(db.markWorkerStartUnknown).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index a3c57a357d27..7e9118daffb5 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -47,7 +47,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { const { runtime, db, run, task, structuredSession, terminalHandle, effects } = args args.onStage('dispatch_input') - const promptDelivery = await deliverWorkerDispatchPreamble({ + const delivery = await deliverWorkerDispatchPreamble({ runtime, structuredSession, terminalHandle, @@ -71,11 +71,11 @@ export async function deliverAndSettleWorkerStartReadiness(args: { // The write above was accepted without waiting on provider hooks; now demand the positive // evidence the receipt claims is observable. A worker whose turn never starts must not be // reported ready — a wedged agent and a working one looked identical before this gate. - // A structured preamble send is acknowledged by the provider or throws, so it is already - // positive evidence. - const turnStart: WorkerTurnStartObservation = structuredSession - ? { verdict: 'observed' } - : await observeWorkerTurnStart({ runtime, terminalHandle, prompt: promptDelivery }) + // A structured preamble send is its own evidence: acknowledged, or still held for its agent. + const promptDelivery = delivery.prompt + const turnStart: WorkerTurnStartObservation = + delivery.structuredTurnStart ?? + (await observeWorkerTurnStart({ runtime, terminalHandle, prompt: promptDelivery })) const deliveredPrompt = turnStart.prompt ?? promptDelivery monitorWorkerSetup({ runtime, @@ -98,7 +98,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { id: terminalHandle, state: 'turn_unobserved' }) - const reason = describeUnobservedWorkerTurnStart(args.agent) + const reason = turnStart.reason ?? describeUnobservedWorkerTurnStart(args.agent) const worker = db.markWorkerStartUnknown( args.dispatchId, 'turn_start_unobserved', @@ -122,7 +122,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { residualResources: JSON.parse(worker.residual_resources) as unknown[], nextCommands: [ `orca orchestration worker-show --dispatch ${args.dispatchId} --json`, - `orca terminal read --terminal ${terminalHandle} --screen`, + // A structured worker has no screen to read. + ...(structuredSession ? [] : [`orca terminal read --terminal ${terminalHandle} --screen`]), `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` ], ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts index d1c9e59adfa5..cc648ee7ddeb 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts @@ -19,6 +19,8 @@ export type WorkerTurnStartVerdict = 'observed' | 'permission' | 'unsupported' | export type WorkerTurnStartObservation = { verdict: WorkerTurnStartVerdict prompt?: RuntimeTerminalPromptDelivery + /** Why an `unobserved` start is unknown, when the default PTY wording does not fit. */ + reason?: string } function classifyPromptDelivery(prompt: RuntimeTerminalPromptDelivery): WorkerTurnStartVerdict { From 283819d17ad4a1b9375506599cf2873cfc5ec15a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:45:59 -0700 Subject: [PATCH 034/101] fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. --- ...nt-session-outbox-rejection-cause.test.tsx | 41 +++++++++++++++++++ src/shared/structured-agent-session-outbox.ts | 8 +++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 815c1a2c4031..fdbb5c67b06a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -214,6 +214,47 @@ describe('a send the host rejected because the agent never started', () => { expect(result.current.error).toBeNull() }) + it('reads a message rejected while the chat was closed as not sent, and sends past it', async () => { + const reason = "Codex couldn't restart: spawn codex ENOENT." + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => pendingResultFor(params.envelope.clientOperationId) + ) + const target = { kind: 'local' } as const + const first = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target, + fence: 1, + submissions: NO_SUBMISSIONS + }) + ) + act(() => expect(first.result.current.send('hello')).toBe(true)) + await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('dispatching')) + const id = first.result.current.outbox[0]!.clientMessageId + first.unmount() + + // Reopened after the start failed, or after a quit settled the message as not sent. + const rejected = [ + { ...pendingResultFor(id).value.submission, dispatchState: 'rejected' as const, reason } + ] + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target, + fence: 1, + submissions: rejected + }) + ) + + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + act(() => expect(result.current.send('second')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + }) + it('keeps the rejection when the journal settles the message before the send answers', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." let answer: (value: unknown) => void = () => undefined diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index f39b07f6627c..1e6458a31b1b 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -125,8 +125,12 @@ export function reconcileStructuredAgentSessionOutbox( return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] } // Accepted, then not delivered — the agent never started, or its start was refused. The text - // stays here for the user's Retry, and nothing queues behind it. - if (submission?.dispatchState === 'rejected' && entry.state === 'dispatching') { + // stays here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a + // remount reads an entry it left dispatching; the journal has since answered it. + if ( + submission?.dispatchState === 'rejected' && + (entry.state === 'dispatching' || entry.state === 'unconfirmed') + ) { return [{ ...entry, state: 'rejected' as const }] } if ( From 40dabe893fb11d27458d4eb77f740bece1767403 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:50:58 -0700 Subject: [PATCH 035/101] test(orchestration): name why the readiness settlement fakes are cast --- .../worker/worker-start-readiness-settlement.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts index 13b31f1ad574..e39a06110dcd 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -29,7 +29,7 @@ function settle(delivered: 'accepted' | undefined) { ? { value: { clientMessageId: 'c1', submission: { dispatchState: delivered } } } : undefined } - const receipt = deliverAndSettleWorkerStartReadiness({ + const args = { runtime: { getNestedWorkerMaxDepth: () => 3, getTerminalOrchestrationCliCommand: () => 'orca' @@ -53,7 +53,9 @@ function settle(delivered: 'accepted' | undefined) { effects: [], terminalRevealWarning: undefined, onStage: () => {} - } as never) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fakes implement exactly the runtime, db and host members this settlement reaches. + const receipt = deliverAndSettleWorkerStartReadiness(args as never) return { db, receipt } } From 31444bf87614945890b19c1a0831627527550aba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:14:53 -0700 Subject: [PATCH 036/101] fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent --- ...ession-history-forward-read-budget.test.ts | 6 +- ...structured-agent-session-attach-context.ts | 9 ++- ...ured-agent-session-attach-orchestration.ts | 4 +- ...d-agent-session-background-task-channel.ts | 3 +- ...tructured-agent-session-client-delivery.ts | 1 - ...-agent-session-command-publication.test.ts | 14 ++-- .../structured-agent-session-host.ts | 3 +- ...red-agent-session-send-preparation.test.ts | 30 ++++++-- ...ructured-agent-session-subscribers.test.ts | 77 +++++++------------ .../structured-agent-session-subscribers.ts | 43 ++++++----- ...tured-agent-session-wire-admission.test.ts | 13 ++-- 11 files changed, 105 insertions(+), 98 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index f240d65273b1..5a9a6f20801c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -105,10 +105,11 @@ describe('forward history SQL read budget', () => { const journal = await seedJournal(count) const { returnedRows, parse } = observeForwardReads() const events: AgentSessionSubscribeEvent[] = [] - new AgentSessionSubscribers({ readFence: () => 1 }).open({ + new AgentSessionSubscribers().open({ id: 'reader', sessionId: identity.sessionId, journal, + fence: 1, cursor: { epoch: journal.epoch, sequence: 1 }, emit: (event) => events.push(event) }) @@ -125,10 +126,11 @@ describe('forward history SQL read budget', () => { const journal = await seedJournal(2_000) const render = vi.spyOn(journalReducer, 'renderJournalState') const events: AgentSessionSubscribeEvent[] = [] - new AgentSessionSubscribers({ readFence: () => 1 }).open({ + new AgentSessionSubscribers().open({ id: 'reader', sessionId: identity.sessionId, journal, + fence: 1, cursor: { epoch: journal.epoch, sequence: 1 }, emit: (event) => events.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 1464ae5ae6ca..4b9b46716cb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -21,8 +21,13 @@ export type StructuredAgentSessionAttachContext = { runtimeState: StructuredAgentSessionHostRuntimeState sessions: Map subscribers: { - reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void - snapshot: (sessionId: string, journal: AgentSessionJournal) => void + reset: ( + sessionId: string, + journal: AgentSessionJournal, + reset: AgentJournalResetReason, + fence: number + ) => void + snapshot: (sessionId: string, journal: AgentSessionJournal, fence: number) => void publish: ( sessionId: string, journal: AgentSessionJournal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index aba50a33ff42..f39a183b0f09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -231,9 +231,9 @@ async function runAttach( ) await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) if (attached.recovery) { - context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset) + context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) } else if (previousFence !== undefined && previousFence !== fence) { - context.subscribers.snapshot(sessionId, attached.journal) + context.subscribers.snapshot(sessionId, attached.journal, fence) } else { context.subscribers.publish(sessionId, attached.journal) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 1fbc73e4afa0..5d922d723503 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -48,6 +48,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { return this.subscribers.open({ ...input, journal: session.journal, + fence: this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) }) } @@ -56,7 +57,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { const session = this.sessions.get(sessionId) const state = publishedState !== undefined ? publishedState : this.state(sessionId) if (session && state !== undefined) { - this.subscribers.backgroundTasks(sessionId, state) + this.subscribers.backgroundTasks(sessionId, state, session.fence) this.onPublished(sessionId) } } 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 34dc46f23b4a..7681e0f34362 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 @@ -36,7 +36,6 @@ export class StructuredAgentSessionClientDelivery { ) this.waitForSendSettlement = this.sendSettlement.wait this.subscribers = new AgentSessionSubscribers({ - readFence: (sessionId) => deps().store.getRecord(sessionId)?.lease.runtimeFence ?? 0, readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId), onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index 80c89f48ce27..b9b7021e854d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -65,25 +65,23 @@ it('delivers catalog changes through existing frames without resending them on o let commands: AgentSessionSlashCommand[] | undefined = [ { name: 'loaded', kind: 'command', kindUnspecified: true } ] - const subscribers = new AgentSessionSubscribers({ - readFence: () => 7, - readCommands: () => commands - }) + const subscribers = new AgentSessionSubscribers({ readCommands: () => commands }) const close = subscribers.open({ id: 'one', sessionId, journal, + fence: 7, emit: coalescer.push }) expect(state.commands).toEqual(commands) for (let i = 0; i < 25; i++) { - subscribers.backgroundTasks(sessionId, null) + subscribers.backgroundTasks(sessionId, null, 7) } coalescer.flush() expect(events.filter((event) => 'commands' in event)).toHaveLength(1) commands = [] subscribers.publish(sessionId, journal) - subscribers.backgroundTasks(sessionId, null) + subscribers.backgroundTasks(sessionId, null, 7) coalescer.flush() expect(state.commands).toEqual([]) expect(events.filter((event) => 'commands' in event)).toHaveLength(2) @@ -98,11 +96,12 @@ it('delivers catalog changes through existing frames without resending them on o sessionId, journal, cursor: journal.cursor(), + fence: 8, emit: coalescer.push }) coalescer.flush() expect(state.commands).toEqual(commands) - subscribers.reset(sessionId, journal, 'epoch_changed') + subscribers.reset(sessionId, journal, 'epoch_changed', 8) expect(state.commands).toEqual(commands) commands = undefined subscribers.open({ @@ -110,6 +109,7 @@ it('delivers catalog changes through existing frames without resending them on o sessionId, journal, cursor: journal.cursor(), + fence: 9, emit: coalescer.push }) coalescer.flush() 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 b13a042a85fa..ba508b67bcf4 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 @@ -116,7 +116,8 @@ export class StructuredAgentSessionHost { store: deps.store, sessions: this.sessions, flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId), - publishFence: (sessionId, session) => this.subscribers.snapshot(sessionId, session.journal), + publishFence: (sessionId, session) => + this.subscribers.snapshot(sessionId, session.journal, session.fence), publishStatus: this.clientDelivery.publishStatusAndSettlement, hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), restartReleaseGrace: (sessionId) => this.holds.renew(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 563cb6609976..c23083fa687d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -685,8 +685,6 @@ describe('a send with no live owner', () => { describe('a write fenced to an owner the pane has not seen replaced', () => { it('delivers a send fenced to the owner an idle release retired', async () => { const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const frames: AgentSessionSubscribeEvent[] = [] - host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) await loseOwner() const params = sendParams('after the release') params.envelope.expectedRuntimeFence = seenFence @@ -699,10 +697,30 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(fence).toBeGreaterThan(seenFence + 1) - // The pane learns the fence the host is at, not the one it subscribed under. - expect(frames.at(-1)).toMatchObject({ fence }) + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) + }) + + // Clients resend a refused message when the fence they hold moves, and a refused restart leaves + // no ledger row, so a frame carrying each failed attempt's fence would resend it forever. + it("keeps the pane's fence on the row a failed restart publishes", async () => { + const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const frames: AgentSessionSubscribeEvent[] = [] + host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) + await loseOwner() + acquire.mockRejectedValueOnce(new Error('Not signed in')) + const subscribed = frames.length + + expect(await host.send(CALLER, sendParams('while signed out'))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_owner_restart_failed' } + }) + + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) + const published = frames.slice(subscribed) + expect(published.length).toBeGreaterThan(0) + for (const frame of published) { + expect(frame).toMatchObject({ fence: seenFence }) + } }) it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index a5a588559cd6..82f8c551642b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -46,7 +46,7 @@ describe('AgentSessionSubscribers', () => { }, journalDir: join(root, 'activity-churn-journal') }) - const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) + const subscribers = new AgentSessionSubscribers() for (let index = 0; index < MAX_RETAINED_SESSION_ACTIVITIES + 4; index += 1) { subscribers.publish(`session-${index}`, journal, { turnId: `turn-${index}`, text: 'working' }) @@ -68,10 +68,11 @@ describe('AgentSessionSubscribers', () => { }) const events: AgentSessionSubscribeEvent[] = [] - new AgentSessionSubscribers({ readFence: () => 7 }).open({ + new AgentSessionSubscribers().open({ id: 'subscriber-1', sessionId: SESSION, journal, + fence: 7, cursor: journal.cursor(), emit: (event) => events.push(event) }) @@ -106,20 +107,20 @@ describe('AgentSessionSubscribers', () => { }) let now = 1_000 const events: AgentSessionSubscribeEvent[] = [] - const subscribers = new AgentSessionSubscribers({ readFence: () => 1, now: () => (now += 1) }) + const subscribers = new AgentSessionSubscribers({ now: () => (now += 1) }) const emit = (event: AgentSessionSubscribeEvent): void => { events.push(event) } - subscribers.open({ id: 'one', sessionId: SESSION, journal, emit }) - subscribers.open({ id: 'two', sessionId: SESSION, journal, emit }) + subscribers.open({ id: 'one', sessionId: SESSION, journal, fence: 1, emit }) + subscribers.open({ id: 'two', sessionId: SESSION, journal, fence: 1, emit }) await journal.appendItem( { provider: 'orca', clientMessageId: 'clocked' }, { kind: 'status', text: 'Clocked' }, { fence: 1 } ) subscribers.publish(SESSION, journal) - subscribers.backgroundTasks(SESSION, null) - subscribers.reset(SESSION, journal, 'epoch_changed') + subscribers.backgroundTasks(SESSION, null, 1) + subscribers.reset(SESSION, journal, 'epoch_changed', 1) expect(events.map((event) => ('hostNow' in event ? event.hostNow : null))).toEqual([ 1_001, 1_002, @@ -151,14 +152,12 @@ describe('AgentSessionSubscribers', () => { }) let commands = [{ name: 'first', kind: 'skill' as const }] const events: AgentSessionSubscribeEvent[] = [] - const subscribers = new AgentSessionSubscribers({ - readFence: () => 7, - readCommands: () => commands - }) + const subscribers = new AgentSessionSubscribers({ readCommands: () => commands }) subscribers.open({ id: 'one', sessionId: SESSION, journal, + fence: 7, emit: (event) => events.push(event) }) expect(events[0]).toMatchObject({ type: 'snapshot', commands }) @@ -177,6 +176,7 @@ describe('AgentSessionSubscribers', () => { sessionId: SESSION, journal, cursor: journal.cursor(), + fence: 7, emit: (event) => events.push(event) }) expect(events[2]).toMatchObject({ type: 'batch', commands }) @@ -195,7 +195,6 @@ describe('AgentSessionSubscribers', () => { }) const published: string[] = [] const subscribers = new AgentSessionSubscribers({ - readFence: () => 1, onJournalPublished: (sessionId, published_journal) => { expect(published_journal).toBe(journal) published.push(sessionId) @@ -203,8 +202,8 @@ describe('AgentSessionSubscribers', () => { }) subscribers.publish(SESSION, journal) - subscribers.reset(SESSION, journal, 'epoch_changed') - subscribers.snapshot(SESSION, journal) + subscribers.reset(SESSION, journal, 'epoch_changed', 1) + subscribers.snapshot(SESSION, journal, 1) expect(published).toEqual([SESSION, SESSION, SESSION]) }) @@ -245,7 +244,6 @@ describe('AgentSessionSubscribers', () => { now: () => 1_000 }) const subscribers = new AgentSessionSubscribers({ - readFence: () => 1, onJournalPublished: (sessionId, published) => statusFeed.publish(sessionId, published) }) const statuses: AgentSessionStatusEvent[] = [] @@ -278,7 +276,7 @@ describe('AgentSessionSubscribers', () => { }) }) - it('publishes background lifecycle without advancing the journal', async () => { + it('publishes background lifecycle without advancing the journal and carries its fence forward', async () => { const journal = await journals.open({ identity: { sessionId: SESSION, @@ -289,12 +287,13 @@ describe('AgentSessionSubscribers', () => { }, journalDir: join(root, 'background-journal') }) - const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) + const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] subscribers.open({ id: 'subscriber-1', sessionId: SESSION, journal, + fence: 1, backgroundTasks: null, emit: (event) => events.push(event) }) @@ -304,51 +303,26 @@ describe('AgentSessionSubscribers', () => { state: 'monitoring' as const, tasks: [{ id: 'task-1', kind: 'command' as const, description: 'run the build' }] } - subscribers.backgroundTasks(SESSION, backgroundTasks) + subscribers.backgroundTasks(SESSION, backgroundTasks, 2) expect(journal.cursor()).toEqual(cursor) expect(events.at(-1)).toEqual({ type: 'batch', sessionId: SESSION, batch: { cursor, items: [], removedItemIds: [], submissions: [] }, - fence: 1, + fence: 2, hostNow: expect.any(Number), backgroundTasks }) - }) - - it('stamps a fence that moved without a replay on the next frame', async () => { - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - journalDir: join(root, 'moved-fence-journal') - }) - let fence = 1 - const subscribers = new AgentSessionSubscribers({ readFence: () => fence }) - const events: AgentSessionSubscribeEvent[] = [] - subscribers.open({ - id: 'subscriber-1', - sessionId: SESSION, - journal, - emit: (event) => events.push(event) - }) - expect(events.at(-1)).toMatchObject({ type: 'snapshot', fence: 1 }) - // An idle release and the resume after it move the lease with no snapshot in between. - fence = 3 await journal.appendItem( - { provider: 'orca', clientMessageId: 'after-release' }, - { kind: 'status', text: 'After the release' }, - { fence: 3 } + { provider: 'orca', clientMessageId: 'after-background-fence' }, + { kind: 'status', text: 'After background state' }, + { fence: 2 } ) subscribers.publish(SESSION, journal) - expect(events.at(-1)).toMatchObject({ type: 'batch', fence: 3 }) + expect(events.at(-1)).toMatchObject({ type: 'batch', fence: 2 }) }) it('publishes latest turn activity without advancing or adding journal rows', async () => { @@ -362,12 +336,13 @@ describe('AgentSessionSubscribers', () => { }, journalDir: join(root, 'activity-journal') }) - const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) + const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] subscribers.open({ id: 'subscriber-1', sessionId: SESSION, journal, + fence: 1, emit: (event) => events.push(event) }) const cursor = journal.cursor() @@ -393,6 +368,7 @@ describe('AgentSessionSubscribers', () => { id: 'reconnected', sessionId: SESSION, journal, + fence: 1, cursor, emit: (event) => events.push(event) }) @@ -464,12 +440,13 @@ describe('AgentSessionSubscribers', () => { journalDir }) - const subscribers = new AgentSessionSubscribers({ readFence: () => 1 }) + const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] subscribers.open({ id: 'subscriber-1', sessionId: SESSION, journal, + fence: 1, cursor: resumeCursor, emit: (event) => events.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index 4c188d8c72d1..af5e231730c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -3,9 +3,6 @@ // Each subscriber advances independently: a client that connected two epochs // ago gets a reset while a caught-up one gets a batch from the same publish. // Nothing raw reaches a subscriber — every event carries reducer output. -// -// Every frame carries the fence read when it is sent. A per-subscriber copy went -// stale whenever the lease moved without a replay, such as an idle release. import type { AgentJournalCursor, @@ -39,12 +36,11 @@ type Subscriber = { sessionId: string emit: AgentSessionSubscriberEmit cursor: AgentJournalCursor + fence: number commands?: AgentSessionSlashCommand[] | null } export type AgentSessionSubscribersHooks = { - /** The session's current runtime fence, stamped on every frame for clients that still read it. */ - readFence: (sessionId: string) => number readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined /** Fires after publications that can change journal content. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void @@ -55,7 +51,7 @@ export class AgentSessionSubscribers { private readonly bySession = new Map>() private readonly activityBySession = new Map() - constructor(private readonly hooks: AgentSessionSubscribersHooks) {} + constructor(private readonly hooks: AgentSessionSubscribersHooks = {}) {} get retainedActivityCountForTests(): number { return this.activityBySession.size @@ -65,6 +61,7 @@ export class AgentSessionSubscribers { id: string sessionId: string journal: AgentSessionJournal + fence: number emit: AgentSessionSubscriberEmit cursor?: AgentJournalCursor backgroundTasks?: AgentSessionBackgroundTaskState | null @@ -74,7 +71,8 @@ export class AgentSessionSubscribers { id: input.id, sessionId: input.sessionId, emit: input.emit, - cursor: input.cursor ?? { epoch: liveCursor.epoch, sequence: 0 } + cursor: input.cursor ?? { epoch: liveCursor.epoch, sequence: 0 }, + fence: input.fence } const session = this.bySession.get(input.sessionId) ?? new Map() session.set(input.id, subscriber) @@ -84,13 +82,12 @@ export class AgentSessionSubscribers { if (input.cursor) { this.deliver(subscriber, input.journal, hostNow, true, input.backgroundTasks) } else { - const fence = this.hooks.readFence(input.sessionId) - const page = readAgentSessionHydrationPage(input.journal, fence) + const page = readAgentSessionHydrationPage(input.journal, input.fence) this.emit(subscriber, { type: 'snapshot', sessionId: input.sessionId, page, - fence, + fence: input.fence, hostNow, ...(input.backgroundTasks !== undefined ? { backgroundTasks: input.backgroundTasks } : {}), ...this.activityField(input.sessionId) @@ -141,26 +138,28 @@ export class AgentSessionSubscribers { sessionId: string, journal: AgentSessionJournal, reason: AgentJournalResetReason, + fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null ): void { - this.replay(sessionId, journal, backgroundTasks, { type: 'reset', reset: reason }) + this.replay(sessionId, journal, fence, backgroundTasks, { type: 'reset', reset: reason }) } snapshot( sessionId: string, journal: AgentSessionJournal, + fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null ): void { - this.replay(sessionId, journal, backgroundTasks, { type: 'snapshot' }) + this.replay(sessionId, journal, fence, backgroundTasks, { type: 'snapshot' }) } private replay( sessionId: string, journal: AgentSessionJournal, + fence: number, backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, frame: { type: 'snapshot' } | { type: 'reset'; reset: AgentJournalResetReason } ): void { - const fence = this.hooks.readFence(sessionId) const page = readAgentSessionHydrationPage(journal, fence) const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { @@ -174,13 +173,17 @@ export class AgentSessionSubscribers { ...this.activityField(sessionId) }) subscriber.cursor = page.liveCursor ?? page.window.nextCursor + subscriber.fence = fence } this.hooks.onJournalPublished?.(sessionId, journal) } - backgroundTasks(sessionId: string, state: AgentSessionBackgroundTaskState | null): void { + backgroundTasks( + sessionId: string, + state: AgentSessionBackgroundTaskState | null, + fence: number + ): void { const hostNow = this.now() - const fence = this.hooks.readFence(sessionId) for (const subscriber of this.subscribers(sessionId)) { this.emit(subscriber, { type: 'batch', @@ -190,6 +193,7 @@ export class AgentSessionSubscribers { backgroundTasks: state, hostNow }) + subscriber.fence = fence } } @@ -209,7 +213,6 @@ export class AgentSessionSubscribers { ? this.activityField(subscriber.sessionId).activity : undefined const publishedActivity = activity !== undefined ? activity : checkpointActivity - const fence = this.hooks.readFence(subscriber.sessionId) const readPage = createAgentSessionCatchUpReader(journal) while (true) { const result = readPage({ @@ -219,13 +222,13 @@ export class AgentSessionSubscribers { limit: AGENT_SESSION_HISTORY_MAX_LIMIT }) if (!result.ok) { - const page = { ...result.page, fence } + const page = { ...result.page, fence: subscriber.fence } this.emit(subscriber, { type: 'reset', sessionId: subscriber.sessionId, reset: result.reset, page, - fence, + fence: subscriber.fence, hostNow, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) @@ -244,7 +247,7 @@ export class AgentSessionSubscribers { type: 'batch', sessionId: subscriber.sessionId, batch: emptyAgentSessionBatch(page.window.nextCursor), - fence, + fence: subscriber.fence, hostNow, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) @@ -261,7 +264,7 @@ export class AgentSessionSubscribers { removedItemIds: page.removedItemIds, submissions: page.submissions }, - fence, + fence: subscriber.fence, hostNow, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 82a7d683a184..585cbd4fbbdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -54,22 +54,21 @@ describe('structured agent-session outbound admission', () => { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES ) - let fence = 1 - const subscribers = new AgentSessionSubscribers({ readFence: () => fence }) + const subscribers = new AgentSessionSubscribers() const initial: AgentSessionSubscribeEvent[] = [] const dispose = subscribers.open({ id: 'initial', sessionId: SESSION, journal, + fence: 1, emit: (event) => initial.push(event) }) expect(initial).toHaveLength(1) expect(initial[0]).toMatchObject({ type: 'snapshot', page: { hasOlder: true } }) expectAdmitted(initial[0]) - fence = 2 - subscribers.backgroundTasks(SESSION, null) - subscribers.snapshot(SESSION, journal) + subscribers.backgroundTasks(SESSION, null, 2) + subscribers.snapshot(SESSION, journal, 2) expect(initial.slice(1)).toHaveLength(2) initial.slice(1).forEach(expectAdmitted) @@ -78,6 +77,7 @@ describe('structured agent-session outbound admission', () => { id: 'old-epoch', sessionId: SESSION, journal, + fence: 2, cursor: { epoch: 'retired-epoch', sequence: 1 }, emit: (event) => epochReset.push(event) }) @@ -109,7 +109,7 @@ describe('structured agent-session outbound admission', () => { }) it('splits valid-cursor catch-up into admitted batch frames', () => { - const subscribers = new AgentSessionSubscribers({ readFence: () => 2 }) + const subscribers = new AgentSessionSubscribers() const catchup: AgentSessionSubscribeEvent[] = [] const firstItemSequence = journal.snapshot().items[0]!.sequence @@ -117,6 +117,7 @@ describe('structured agent-session outbound admission', () => { id: 'catchup', sessionId: SESSION, journal, + fence: 2, cursor: { epoch: journal.epoch, sequence: firstItemSequence }, emit: (event) => catchup.push(event) }) From 494d8c7ac14edf403a108c4eb9de9deaa0e1633d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:26:32 -0700 Subject: [PATCH 037/101] docs(native-chat): drop the fence from the admission the send effects run behind --- .../agent-session-wire/structured-agent-session-turns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index bea393eddbbb..25df6aed9d33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -1,6 +1,6 @@ // The effects behind send / cancel / respond / setOption. // -// Admission (lease, fence, idempotency) has already passed by the time anything +// Admission (writer lease, idempotency) has already passed by the time anything // here runs; these functions own only the journal writes and the adapter call, // in that order. Journal first is deliberate: a crash between the two leaves a // row the next attach settles as `unknown`, whereas the reverse would lose a From 0e74529dff9725b3fcb0ea8488e5a85f2fa9a086 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:36:45 -0700 Subject: [PATCH 038/101] docs(native-chat): give the fence move on release the reason that still holds --- src/main/runtime/agent-session-surface-release-transition.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index a6c90d1e01fa..b78517095869 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -5,8 +5,8 @@ // lease-owning provider root through the adapter. Its observed exit is sufficient because the // lease follows that root, even when descendants remain `unverifiable`. // -// The fence still moves. A released lease at the old fence would let a mutation a client queued -// against the dead generation land on the next one. +// The fence still moves, so the next owner is a new generation: an attach or settlement still +// holding the stopped owner's fence is refused as stale rather than acting on its successor. import type { AgentSessionRecord } from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' From 6c16f353c6ae76f3a0bf4deb9d3a778d2bb6ff83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:42:55 -0700 Subject: [PATCH 039/101] docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. --- src/main/agent-launch/agent-launch-executor.test.ts | 2 +- src/main/agent-launch/agent-launch-surface-factories.ts | 4 ++-- src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts | 2 +- .../structured-worker-direct-mailbox-target.test.ts | 2 +- src/main/runtime/rpc/methods/agent-launch-replay.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index a95e71a30ef3..e7270287fb53 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -265,7 +265,7 @@ describe('the prompt receipt', () => { 'createStructuredSession', 'deliverStructuredPrompt' ]) - // The send must name the lease the create was admitted under, not one re-read later. + // The send carries the create's own fence; nothing re-reads the session for it. expect(h.deliverStructuredPrompt).toHaveBeenCalledWith({ sessionId: 'sess-1', fence: 4, diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 11d72274bd4f..14d2d4b2797d 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -64,8 +64,8 @@ export type AgentLaunchSurfaceFactory = { deliverTerminalPrompt?(args: { handle: string; prompt: AgentLaunchPrompt }): Promise } -/** `fence` is carried out of the create because a send must name the lease it was admitted against, - * and re-reading it later would read whatever fence the session has by then. */ +/** `fence` is the lease the create was admitted at, carried so the launch prompt's send can fill its + * envelope without re-reading the session; the host does not check a write's fence. */ export type AgentLaunchStructuredSurface = { sessionId: string handle: string diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 564555513496..e26301e379a2 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -246,7 +246,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM * The worker's ACTIVE dispatch is preferred when it has one, so peer and coordinator nudges share * one operation-ledger budget and one set of retain rules. A worker BETWEEN dispatches is still * nudged, under a session-scoped budget: the mail is durable, the session is live, and a dispatch - * says nothing about whether delivery is safe — the idle gate and the lease fence do that. + * says nothing about whether delivery is safe — the idle gate and the writer lease do that. */ protected resolveStructuredWorkerDirectMailboxTarget( handle: string diff --git a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts index 4fff2d387d0c..cc32156cc873 100644 --- a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts +++ b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts @@ -93,7 +93,7 @@ describe('the mailbox target for direct peer mail to a structured worker', () => // claimed the mailbox — the PTY lane refuses a structured handle outright and this resolver // answered only `dispatch:` addresses. The worker never reacted and the peer waiting on a // reply hung, with nothing logged. A dispatch says nothing about whether delivery is safe; - // the idle gate and the lease fence do, and both still run downstream. + // the idle gate and the writer lease do, and both still run downstream. const handle = registerWorker() installRecord({ runtimeKind: 'native', claimStatus: 'live' }) expect(probe(undefined).instance.probeResolveTarget(handle)).toEqual({ diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index 223d4a56c9ed..ec9269f4efea 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -11,8 +11,8 @@ * resolving the caller's worktree selector, because a selector resolution is a live precondition * and a replay must not be able to fail on one: an operation that already ran has an answer, and * re-deciding it against today's world is how a recorded success becomes a fresh refusal the client - * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the lease - * and the fence for that same reason. + * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the writer + * lease for that same reason. */ import { deriveAgentLaunchChildOperationId } from '../../../../shared/agent-launch-operation' From d2f646b87002b6c86254ee970c81b09c5f288064 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:47:49 -0700 Subject: [PATCH 040/101] refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. --- ...-agent-session-accept-then-deliver.test.ts | 12 + ...structured-agent-session-attach-failure.ts | 1 + .../structured-agent-session-attach-flow.ts | 2 + ...ured-agent-session-attach-orchestration.ts | 134 +++++---- ...red-agent-session-claude-root-exit.test.ts | 9 +- ...ructured-agent-session-close-retry.test.ts | 5 +- ...uctured-agent-session-conversation-open.ts | 9 +- ...ctured-agent-session-conversations.test.ts | 5 +- .../structured-agent-session-delivery-loop.ts | 16 +- ...structured-agent-session-event-recovery.ts | 7 +- .../structured-agent-session-eviction.test.ts | 30 +- .../structured-agent-session-eviction.ts | 17 +- ...ctured-agent-session-forget-status.test.ts | 5 +- .../structured-agent-session-holds.test.ts | 6 +- .../structured-agent-session-host-delivery.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 130 +++++--- ...structured-agent-session-host-mutations.ts | 35 ++- .../structured-agent-session-host-types.ts | 46 ++- .../structured-agent-session-host.ts | 13 +- ...ured-agent-session-journal-handles.test.ts | 71 ++--- ...gent-session-provider-child-record.test.ts | 278 ++++++++++++++++++ ...structured-agent-session-provider-child.ts | 66 +++++ ...ructured-agent-session-provider-started.ts | 10 +- ...uctured-agent-session-read-restore.test.ts | 2 +- ...ured-agent-session-restart-continuation.ts | 13 +- ...ured-agent-session-restart-restore.test.ts | 7 +- ...tured-agent-session-restart-resume-host.ts | 7 +- ...ent-session-restart-resume-test-harness.ts | 2 +- ...tured-agent-session-restart-resume.test.ts | 32 +- .../structured-agent-session-reveal.ts | 10 +- ...red-agent-session-settlement-retry.test.ts | 2 +- ...ructured-agent-session-settlement-retry.ts | 77 ++--- ...uctured-agent-session-start-failure-row.ts | 2 +- ...agent-session-startup-failure-exit.test.ts | 9 +- ...-agent-session-status-feed-test-session.ts | 10 +- ...ructured-agent-session-status-feed.test.ts | 27 +- .../structured-agent-session-status-feed.ts | 18 +- ...red-agent-session-subagent-recency.test.ts | 4 +- ...red-agent-session-surface-lifetime.test.ts | 12 +- ...ession-unanswered-dispatch-release.test.ts | 10 +- ...ured-agent-session-unexpected-exit.test.ts | 38 +-- ...tructured-agent-session-unexpected-exit.ts | 73 ++--- ...tured-agent-session-working-at-teardown.ts | 7 +- ...ree-structured-agent-rows-liveness.test.ts | 2 +- ...x-child-approval-activity-row.unit.test.ts | 4 +- 45 files changed, 849 insertions(+), 434 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 8fc325a57e4e..91792f5875b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -392,6 +392,15 @@ describe('a start the chat needed and did not get', () => { describe('an attach that fails after indexing its child', () => { it('leaves no child behind, so the next send starts one and is delivered', async () => { await host.close(SESSION) + const owned: boolean[] = [] + host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status') { + owned.push(event.session.hostExecutionOwned === true) + } + } + }) const first = await accept('hello') // The attach's own success record is the step after `onAttached` indexed the child. const record = vi.spyOn(store, 'recordOperationOutcome') @@ -403,6 +412,9 @@ describe('an attach that fails after indexing its child', () => { return AgentSessionRecordStore.prototype.recordOperationOutcome.call(store, input) }) await eventually(() => expect(submission(first)?.dispatchState).toBe('rejected')) + // Nothing was indexed, so nothing had to be taken back: no list ever showed a child. + expect(host['sessions'].get(SESSION)?.child).toBeNull() + expect(owned).not.toContain(true) const acquiresBefore = acquire.mock.calls.length const next = await accept('after the failure') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 533f539466c5..89fb4654f66c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -24,6 +24,7 @@ export async function settlePostAcquisitionAttachFailure( ? 'root-exit-observed' : 'exit-proven' } + input.onAcquisitionReleased?.(cause) try { await input.store.settleFailedPostAcquisitionAttachment({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 3498b6f5d88f..5390e3f0f1e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -69,6 +69,8 @@ export type AttachFlowInput = { beforeJournalOpen?: () => Promise | void /** The host's open conversation, which the attach adopts rather than opening a second one. */ openConversation?: (sessionId: string) => Promise + /** A failure after acquisition released the session's acquisition; `cause` is that failure. */ + onAcquisitionReleased?: (cause: unknown) => void } export async function performAttach( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 6aeb6bc46d59..c93dabd549d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -24,7 +24,12 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import { + endProviderChild, + indexProviderChild, + structuredAgentSessionConversationFence +} from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -94,6 +99,10 @@ async function runAttach( ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase + // Readers of a conversation already open are re-baselined when this attach moves its fence. + const fenceBefore = context.sessions.has(sessionId) + ? structuredAgentSessionConversationFence(context.deps.store, sessionId) + : null if (options.admitRecoveryTicket && !options.admitRecoveryTicket()) { return refuseAgentSessionMutation({ code: 'agent_session_checkpoint_stale', @@ -114,9 +123,8 @@ async function runAttach( const settled = await withAgentSessionCreatePhase('settlement_retry', recordPhase, () => retryPendingStructuredAgentSessionSettlement({ deps: context.deps, - sessions: context.sessions, sessionId, - params, + openJournal: async () => (await context.openConversation(sessionId))?.journal ?? null, now: () => context.now() }) ) @@ -130,16 +138,15 @@ async function runAttach( context.runtimeState.probeOwner(sessionId) ) // A child this attach spawns writes through a sink this attempt owns. Only a successful - // attach makes it the session's; any other exit closes it with whatever the child queued. + // attach makes the child and its sink the session's; any other exit closes the sink with + // whatever the child queued, and leaves the conversation's child as it was. const attemptSink = context.runtimeState.mintEventSink(sessionId) - let attemptSinkAdopted = false - let indexed: StructuredAgentSessionHostSession | null = null - let attachedOk = false - const attached = stampFailedCreateOwnerVerdict( - context.deps.store, - callerKey, - params.envelope, - await performAttach({ + const attempt: { candidate: AttachCandidate | null; committed: boolean } = { + candidate: null, + committed: false + } + try { + const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, journalRoot: context.deps.journalRoot, @@ -163,8 +170,6 @@ async function runAttach( params, now: () => context.now(), recordPhase, - // A failed attach leaves the conversation open, with its queued messages, for the failure - // to be written into; only the attempt's own sink is discarded, in the `finally` below. openConversation: async (id) => { const conversation = await context.openConversation(id) if (!conversation) { @@ -172,15 +177,15 @@ async function runAttach( } return conversation.journal }, + // The cleanup released the acquisition, which for a re-attach is the live child itself. + onAcquisitionReleased: (cause) => endReleasedChild(context, sessionId, cause), onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { - const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 - const previous = context.sessions.get(sessionId) - const previousFence = previous?.fence + const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) + const current = context.sessions.get(sessionId)?.child ?? null // A re-attach to a live child keeps the sink that child already writes through. const eventSink = acquiredOwner ? attemptSink : (context.runtimeState.currentEventSink(sessionId) ?? attemptSink) - // The journal is the conversation's own open one, so a throw here leaves it indexed. if (acquiredOwner) { // Before the drain: the buffered events are the new child's, never a stale row's. await settleStaleSessionStateOnAcquire({ @@ -193,20 +198,15 @@ async function runAttach( await bindAndDrain(eventSink, attached.journal, fence, (activity) => context.subscribers.publish(sessionId, attached.journal, activity) ) - context.runtimeState.adoptEventSink(sessionId, eventSink) - attemptSinkAdopted = eventSink === attemptSink - indexed = { - journal: attached.journal, - params, - fence, - hasProviderChild: true, - // A re-attach to a live child keeps what that child already proved. - providerChildPhase: acquiredOwner - ? providerChildPhase - : (previous?.providerChildPhase ?? 'ready'), - acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null + attempt.candidate = { + sink: eventSink, + child: { + generation: acquisitionGeneration ?? current?.generation ?? null, + fence, + // A re-attach to a live child keeps what that child already proved. + phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready') + } } - context.sessions.set(sessionId, indexed) await recoverStructuredRewind( context.deps.store, sessionId, @@ -216,45 +216,57 @@ async function runAttach( context.now ) await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) - if (previousFence !== undefined && previousFence !== fence) { + if (fenceBefore !== null && fence !== fenceBefore) { context.subscribers.snapshot(sessionId, attached.journal) } else { context.subscribers.publish(sessionId, attached.journal) } } }) - .then((result) => { - attachedOk = result.ok - return result - }) - .finally(() => { - const releasedIndexedChild = - !attachedOk && indexed !== null && context.sessions.get(sessionId) === indexed - if (releasedIndexedChild && indexed) { - // The failure path released the child this attempt indexed; the conversation stays. - indexed.hasProviderChild = false - indexed.providerChildPhase = 'ready' - context.runtimeState.currentEventSink(sessionId)?.close() - context.runtimeState.discardEventSink(sessionId) - context.publishStatus?.(sessionId) - } - if (!attemptSinkAdopted || releasedIndexedChild) { - attemptSink.close() - followRecordFence(context, sessionId) - } - }) - ) - return attached + const { candidate } = attempt + const conversation = context.sessions.get(sessionId) + if (attached.ok && candidate && conversation) { + context.runtimeState.adoptEventSink(sessionId, candidate.sink) + attempt.committed = candidate.sink === attemptSink + indexProviderChild(conversation, candidate.child) + context.publishStatus?.(sessionId) + } + return stampFailedCreateOwnerVerdict(context.deps.store, callerKey, params.envelope, attached) + } finally { + if (!attempt.committed) { + attemptSink.close() + } + } } -/** A failed attempt still moved the record's fence when it reserved; the conversation it leaves - * open writes its failure row and rejections at that fence, not the one it opened at. */ -function followRecordFence(context: StructuredAgentSessionAttachContext, sessionId: string): void { - const conversation = context.sessions.get(sessionId) - const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence - if (conversation && !conversation.hasProviderChild && fence !== undefined) { - conversation.fence = Math.max(conversation.fence, fence) +type AttachCandidate = { + child: StructuredAgentSessionProviderChild + sink: DeferredStructuredAgentSessionEventSink +} + +function endReleasedChild( + context: StructuredAgentSessionAttachContext, + sessionId: string, + cause: unknown +): void { + const session = context.sessions.get(sessionId) + const child = session?.child + if ( + !session || + !child || + !endProviderChild(session, { + generation: child.generation, + fence: child.fence, + cause: 'attach-failed', + reason: cause instanceof Error ? cause.message : String(cause), + duringStartup: child.phase === 'starting' + }) + ) { + return } + context.runtimeState.currentEventSink(sessionId)?.close() + context.runtimeState.discardEventSink(sessionId) + context.publishStatus?.(sessionId) } /** Binds the sink to the journal and waits for the barrier the host publishes diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index c449711e7453..2637db9356df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -103,10 +103,11 @@ describe('Claude root-exit eviction', () => { { journal, params, - fence, - hasProviderChild: true, - providerChildPhase: 'ready', - acquisitionGeneration: acquisition.acquisitionGeneration ?? null + child: { + generation: acquisition.acquisitionGeneration ?? null, + fence, + phase: 'ready' + } } ] ]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts index 8c99be697c51..303ecbe92fc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts @@ -78,10 +78,7 @@ function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSe return { journal, params: {} as StructuredAgentSessionHostSession['params'], - fence: 1, - hasProviderChild: false, - providerChildPhase: 'ready', - acquisitionGeneration: null + child: null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 5b20c537cfc1..eb850e1941f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -96,14 +96,7 @@ export async function openStructuredAgentSessionConversationJournal( deps.onEventSinkError?.({ sessionId, error }) } return { - session: { - journal: opened.journal, - params, - fence, - hasProviderChild: false, - providerChildPhase: 'ready', - acquisitionGeneration: null - }, + session: { journal: opened.journal, params, child: null }, reset: opened.recovery?.reset ?? null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index 202ffe50ae4d..7014e3947283 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -37,10 +37,7 @@ function session(journal: AgentSessionJournal) { return { journal, params: hostTestAttachParams(null), - fence: 0, - hasProviderChild: false, - providerChildPhase: 'ready' as const, - acquisitionGeneration: null + child: null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 7bdc65119c62..9d5246339595 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -24,6 +24,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { serialize: (sessionId: string, task: () => Promise) => Promise /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ ensureProviderChild: (sessionId: string) => Promise + /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ + conversationFence: (sessionId: string) => number /** What the chat says when the session could not be made ready. */ startFailureText: (sessionId: string, cause: AgentSessionWireRefusal) => string onError: (sessionId: string, error: unknown) => void @@ -99,7 +101,7 @@ export class StructuredAgentSessionDeliveryLoop { return this.stop(sessionId) } await session.journal.rejectQueuedSubmissions( - session.fence, + this.deps.conversationFence(sessionId), DISPATCH_REJECTED_HOST_RESTARTED, // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) @@ -116,7 +118,8 @@ export class StructuredAgentSessionDeliveryLoop { return this.stop(sessionId) } // Re-derived here, not carried from the start: the child may have gone since. - if (!session.hasProviderChild) { + const { child } = session + if (!child) { return 'continue' } const next = oldestQueuedSubmission(session) @@ -127,9 +130,9 @@ export class StructuredAgentSessionDeliveryLoop { { sessionId, journal: session.journal, - fence: session.fence, + fence: child.fence, adapter: this.deps.adapter, - providerChildPhase: () => this.deps.sessions.get(sessionId)?.providerChildPhase + providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase }, next ) @@ -139,7 +142,10 @@ export class StructuredAgentSessionDeliveryLoop { private async fail(sessionId: string, text: string): Promise { const session = this.deps.sessions.get(sessionId) if (session) { - await recordStructuredAgentSessionStartFailure(session, text) + await recordStructuredAgentSessionStartFailure( + { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, + text + ) } return this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 89a492130884..ac3057b81e8b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -39,14 +39,13 @@ export class StructuredAgentSessionEventRecovery { this.sinkFailures.add(sessionId) void this.context .serialize(sessionId, async () => { - const session = this.context.sessions.get(sessionId) + const child = this.context.sessions.get(sessionId)?.child const stop = this.context.deps.adapter.forceCloseSession ?? this.context.deps.adapter.closeSession - if (!session?.hasProviderChild || !stop) { + if (!child || !stop) { return null } - const fence = session.fence - const acquisitionGeneration = session.acquisitionGeneration + const { fence, generation: acquisitionGeneration } = child const stopped = await stop(sessionId) if (!stopped || !acquisitionGeneration) { return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index cb0cb2fb2334..83f8ade9a4c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -30,8 +30,8 @@ function context(): StructuredAgentSessionEvictionContext & { order: string[] } return true }) } as unknown as StructuredAgentSessionEvictionContext['adapter'], - forget: vi.fn(async () => { - order.push('forget') + acknowledgeRelease: vi.fn(() => { + order.push('acknowledgeRelease') }), discardSink: vi.fn(() => order.push('discardSink')), settleWork: vi.fn(async () => { @@ -51,7 +51,7 @@ function runtimeState(): StructuredAgentSessionHostRuntimeState { } describe('structured agent session eviction', () => { - it('stops the child before it lets the sink go, then forgets the session', async () => { + it('stops the child before it lets the sink go, then acknowledges the release', async () => { const ctx = context() await evictStructuredAgentSession(ctx) expect(ctx.order).toEqual([ @@ -62,7 +62,7 @@ describe('structured agent session eviction', () => { 'close', 'discardSink', 'releaseLease', - 'forget' + 'acknowledgeRelease' ]) }) @@ -90,7 +90,7 @@ describe('structured agent session eviction', () => { 'close-sink', 'discard-sink', 'release-lease', - 'forget-session' + 'acknowledge-release' ]) }) @@ -114,7 +114,7 @@ describe('structured agent session eviction', () => { } }) - it('aborts after a failed drain barrier without unbinding or forgetting the session', async () => { + it('aborts after a failed drain barrier without unbinding or acknowledging the release', async () => { const ctx = context() ctx.eventSink.drained = vi.fn(async () => { ctx.order.push('drained') @@ -128,7 +128,7 @@ describe('structured agent session eviction', () => { expect(ctx.eventSink.close).not.toHaveBeenCalled() expect(ctx.discardSink).not.toHaveBeenCalled() expect(ctx.releaseLease).not.toHaveBeenCalled() - expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() expect(ctx.order).toEqual(['closeSession', 'drained']) }) }) @@ -154,9 +154,9 @@ describe('rows the provider emits while closing', () => { return true } } as never, - forget: async () => {}, discardSink: () => state.discardEventSink(sessionId), - releaseLease: async () => {} + releaseLease: async () => {}, + acknowledgeRelease: () => {} }) expect(published).toEqual(['final-flush']) @@ -184,18 +184,18 @@ describe('a child that will not stop', () => { 'close', 'discardSink', 'releaseLease', - 'forget' + 'acknowledgeRelease' ]) }) - it('aborts without forgetting the session, so the next close is a real retry', async () => { + it('aborts without acknowledging the release, so the next stop is a real retry', async () => { const ctx = context() ctx.adapter.closeSession = vi.fn(async () => false) await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ step: 'stop-provider-child' }) - expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() expect(ctx.discardSink).not.toHaveBeenCalled() expect(ctx.order).toEqual([]) }) @@ -210,7 +210,7 @@ describe('a child that will not stop', () => { StructuredAgentSessionEvictionError ) expect(ctx.eventSink.close).not.toHaveBeenCalled() - expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() }) }) @@ -225,9 +225,9 @@ describe('eviction against the real sink cache', () => { sessionId, eventSink: state.eventSinkFor(sessionId), adapter: { closeSession: async () => true } as never, - forget: async () => {}, discardSink: () => state.discardEventSink(sessionId), - releaseLease: async () => {} + releaseLease: async () => {}, + acknowledgeRelease: () => {} }) const published: string[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 951bbd2ab322..5ff00733dee7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -1,4 +1,4 @@ -// Releasing one structured session's resources. +// Stopping one structured session's provider child and handing its lease back. // // Teardown is a DATA list, not a method body, for the reason this file exists at all: the host // tracked which sessions were live in a map, and tore them down at three unrelated call sites @@ -30,10 +30,9 @@ export type StructuredAgentSessionEvictionContext = { hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter - /** Closes the session's journal handle and drops the map entry. Async and - * awaited: `close()` is ordered behind queued writes, and a delete that - * returns while the close is still queued leaves nothing to retry. */ - forget: () => Promise + /** Tells the adapter the released lease is done with, so it drops this child's route and index. + * The conversation stays: stopping the agent never closes its journal. */ + acknowledgeRelease: () => Promise | void /** Drops the cached sink so a later attach mints a fresh one. */ discardSink: () => void /** Fires right before the stop, while the child's turn and background roster are still live. A @@ -126,11 +125,11 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // these two; eviction has to as well. { name: 'discard-sink', run: (context) => context.discardSink() }, // Why here and not last: the durable lease still names a process this host just stopped, and a - // record left claiming a live owner is one nothing can resume — the next surface to open the - // chat would find a session it may not acquire. Placed BEFORE forget so a release that cannot - // be written aborts while the session is still indexed, which is what makes the retry real. + // record left claiming a live owner is one nothing can resume — the next send would find a + // session it may not acquire. Placed BEFORE the acknowledgement so a release that cannot be + // written aborts while the adapter still routes the session, which is what makes the retry real. { name: 'release-lease', run: (context) => context.releaseLease() }, - { name: 'forget-session', run: (context) => context.forget() } + { name: 'acknowledge-release', run: (context) => context.acknowledgeRelease() } ] export class StructuredAgentSessionEvictionError extends Error { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 304cadf8f860..1ffc08a02493 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -135,10 +135,7 @@ async function workingSession(): Promise<{ accountHome: ownerRecord().accountHome, runtimeKind: 'native' }, - fence: 1, - hasProviderChild: true, - providerChildPhase: 'ready', - acquisitionGeneration: null + child: { generation: null, fence: 1, phase: 'ready' } } ] ]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts index 68027f81887c..9b6718e99575 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts @@ -296,7 +296,7 @@ describe('holds', () => { describe('the teardown deadline', () => { it('leaves the child loaded instead of forcing it, and keeps the session indexed', async () => { - const forget = vi.fn() + const acknowledgeRelease = vi.fn() const releaseLease = vi.fn(async () => {}) await expect( @@ -309,7 +309,7 @@ describe('the teardown deadline', () => { close: vi.fn() } as never, adapter: { closeSession: () => new Promise(() => {}) } as never, - forget, + acknowledgeRelease, discardSink: vi.fn(), releaseLease }, @@ -317,7 +317,7 @@ describe('the teardown deadline', () => { ) ).rejects.toMatchObject({ step: 'stop-provider-child' }) - expect(forget).not.toHaveBeenCalled() + expect(acknowledgeRelease).not.toHaveBeenCalled() expect(releaseLease).not.toHaveBeenCalled() }) 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 3065c464329f..8b25364c8819 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 @@ -15,6 +15,7 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionStartFailureText } from './structured-agent-session-send-preparation' import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' @@ -44,6 +45,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { adapter: deps.adapter, serialize: input.serialize, ensureProviderChild: input.ensureProviderChild, + conversationFence: (sessionId) => + structuredAgentSessionConversationFence(deps.store, sessionId), startFailureText: (sessionId, cause) => structuredAgentSessionStartFailureText(deps.store.getRecord(sessionId), cause), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) @@ -82,9 +85,10 @@ async function settleInterruptedCommands( sessionId: string, session: StructuredAgentSessionHostSession ): Promise { + const fence = structuredAgentSessionConversationFence(deps.store, sessionId) try { - await settleInterruptedCompaction(deps.store, sessionId, session.journal, session.fence) - await recoverStructuredRewind(deps.store, sessionId, session.journal, session.fence) + await settleInterruptedCompaction(deps.store, sessionId, session.journal, fence) + await recoverStructuredRewind(deps.store, sessionId, session.journal, fence) } catch (error) { deps.onEventSinkError?.({ sessionId, error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index e3b284acb5be..910350982571 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -18,8 +18,13 @@ import { StructuredAgentSessionHolds } from './structured-agent-session-holds' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { + endProviderChild, + structuredAgentSessionConversationFence +} from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' @@ -32,6 +37,9 @@ export type StructuredAgentSessionLifetimeContext = { now: () => number /** Drops the session's row from the agent-status store; see `forgetStructuredAgentSession`. */ forgetStatus: (sessionId: string) => void + /** Re-projects the session's status and fence after its agent stopped and the chat stays. */ + publishStatus?: (sessionId: string) => void + publishFence?: (sessionId: string) => void /** Quit-only snapshot taken immediately before the provider child is stopped. */ restartWitness?: { beforeStop: (sessionId: string) => void @@ -43,12 +51,19 @@ export type StructuredAgentSessionLifetimeContext = { * told, so a caller that only deletes strands a live-looking row no reader can ever decay. A * handle closes with nothing queued: what is still queued now will not be handed over. */ export async function forgetStructuredAgentSession( - context: StructuredAgentSessionLifetimeContext, + context: Pick & { + deps: Pick & { + store: Pick + } + }, sessionId: string ): Promise { const session = context.sessions.get(sessionId) await session?.journal - .rejectQueuedSubmissions(session.fence, DISPATCH_REJECTED_PROVIDER_CLOSED) + .rejectQueuedSubmissions( + structuredAgentSessionConversationFence(context.deps.store, sessionId), + DISPATCH_REJECTED_PROVIDER_CLOSED + ) // Best effort: the next open rejects a leftover itself. .catch((error: unknown) => context.deps.onEventSinkError?.({ sessionId, error })) await session?.journal.close() @@ -60,58 +75,75 @@ function hasProviderChild( context: StructuredAgentSessionLifetimeContext, sessionId: string ): boolean { - return context.sessions.get(sessionId)?.hasProviderChild === true + return (context.sessions.get(sessionId)?.child ?? null) !== null } /** The wind-down this host owes for the session's child. A live child always owes one, whatever a - * previous childless eviction recorded: a remembered `false` must never outrank the child in front - * of it. */ -function owesProviderChildWindDown(session: StructuredAgentSessionHostSession): boolean { - return session.hasProviderChild || session.owesProviderChildWindDown === true + * previous childless eviction recorded: a remembered tombstone must never outrank the child in + * front of it. */ +function owedProviderChildWindDown( + session: StructuredAgentSessionHostSession +): StructuredAgentSessionProviderChildIdentity | undefined { + return session.child + ? { generation: session.child.generation, fence: session.child.fence } + : session.owesProviderChildWindDown } -/** Runs the eviction steps under a deadline. A step that fails — or runs out of time — aborts the - * rest, which leaves the session indexed and the child loaded so the next close is a real retry. */ -export async function evictHeldStructuredAgentSession( +/** + * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step + * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next + * stop is a real retry. `ending` is how the child's end is told: a Stop, or an eviction whose close + * forgets the conversation next. + */ +export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, - sessionId: string + sessionId: string, + ending: 'stop' | 'evict' = 'stop' ): Promise { const session = context.sessions.get(sessionId) if (!session) { return } - // The obligation OUTLIVES the child. `hasProviderChild` is retired the instant the adapter - // proves the exit, so a step that aborts after that point would otherwise leave the retry - // reading "no child here" and skipping the settlement and the lease release it still owes. - const owesWindDown = owesProviderChildWindDown(session) - session.owesProviderChildWindDown = owesWindDown + // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, + // so a step that aborts after that point would otherwise leave the retry reading "no child + // here" and skipping the settlement and the lease release it still owes. + const owed = owedProviderChildWindDown(session) + session.owesProviderChildWindDown = owed + const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { sessionId, // The retry must not re-stop a child the adapter already proved gone, so this stays honest. - hasProviderChild: session.hasProviderChild, - owesProviderChildWindDown: owesWindDown, + hasProviderChild: stopping !== null, + owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, ...(context.restartWitness ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), - // Host state must not disagree with the adapter for the seven steps in between. + // Host state must not disagree with the adapter for the steps in between. onProviderChildStopped: () => { - session.hasProviderChild = false + if (stopping) { + endProviderChild(session, { + generation: stopping.generation, + fence: stopping.fence, + cause: ending, + reason: null, + duringStartup: stopping.phase === 'starting' + }) + } context.restartWitness?.stopped(sessionId) }, - forget: async () => { - await forgetStructuredAgentSession(context, sessionId) - context.deps.adapter.acknowledgeSessionRelease?.(sessionId) - }, + acknowledgeRelease: () => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), discardSink: () => context.runtimeState.discardEventSink(sessionId), settleWork: async () => { + const fence = + owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, - fence: session.fence, - settlementId: `expected-close:${sessionId}:${session.fence}:${session.acquisitionGeneration ?? 'unknown'}`, + fence, + settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, @@ -127,15 +159,23 @@ export async function evictHeldStructuredAgentSession( } }, releaseLease: async () => { - await releaseStoredStructuredAgentSessionOwner({ - store: context.deps.store, - sessionId, - hasProviderChild: owesWindDown, - expectedFence: session.fence, - now: context.now() - }) - session.owesProviderChildWindDown = false - context.forgetStatus(sessionId) + if (owed) { + await releaseStoredStructuredAgentSessionOwner({ + store: context.deps.store, + sessionId, + hasProviderChild: true, + expectedFence: owed.fence, + now: context.now() + }) + } + session.owesProviderChildWindDown = undefined + if (ending === 'evict') { + context.forgetStatus(sessionId) + return + } + // The conversation stays open at the fence the release moved it to. + context.publishStatus?.(sessionId) + context.publishFence?.(sessionId) } } await evictStructuredAgentSession( @@ -144,6 +184,19 @@ export async function evictHeldStructuredAgentSession( ) } +/** Ends the conversation's resources, not the conversation: its child stops, and then its handle + * closes and it leaves the map. A stop that fails throws first, leaving it indexed for a retry. */ +export async function evictHeldStructuredAgentSession( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + if (!context.sessions.has(sessionId)) { + return + } + await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, 'evict') + await forgetStructuredAgentSession(context, sessionId) +} + /** Stops every provider child owned by this host while keeping failed evictions reachable. A * session whose child is already stopped but whose wind-down aborted is still in scope — that is * the retry. */ @@ -154,7 +207,7 @@ export async function evictOwnedStructuredAgentSessions( retainOnFailure: Set ): Promise { const ownedSessionIds = [...context.sessions] - .filter(([, session]) => owesProviderChildWindDown(session)) + .filter(([, session]) => owedProviderChildWindDown(session) !== undefined) .map(([sessionId]) => sessionId) // Retained up front and cleared only once an eviction settles: the quit phase is bounded, and a // timeout leaves these still running. Closing their journals underneath them is the one outcome @@ -215,8 +268,7 @@ export function createStructuredAgentSessionHolds( ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null || deliveryActive(sessionId) || session.journal.submissions().some(isQueuedAgentJournalSubmission) || - (session.providerChildPhase === 'starting' && - session.journal.pendingSubmissions().length > 0) + (session.child?.phase === 'starting' && session.journal.pendingSubmissions().length > 0) : false }, onError: (error) => context.deps.onEventSinkError?.(error), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 971c63aa6fca..5f2e7bd74527 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -23,6 +23,7 @@ import type { } from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { threadGoalPlan } from './structured-agent-session-thread-goal' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, type AgentSessionMutationRequest @@ -55,8 +56,8 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void - /** Stops a provider child that has not proven its start; inside the caller's serialize. */ - stopStartingChild: (sessionId: string) => Promise + /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ + stopAgent: (sessionId: string) => Promise now: () => number } @@ -78,7 +79,7 @@ function mutate( prepareSession, publish: (journal) => context.publish(envelope.sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, - providerChildPhase: () => context.sessions.get(envelope.sessionId)?.providerChildPhase, + providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, now: () => context.now() }) ) @@ -154,13 +155,13 @@ export function cancelStructuredAgentSessionTurn( ctx.fence, DISPATCH_REJECTED_CANCELLED ) - const session = context.sessions.get(ctx.sessionId) - if (session?.hasProviderChild && session.providerChildPhase === 'starting') { - // A start that may never land is the one thing here Stop has to end. - await context.stopStartingChild(ctx.sessionId) + const child = context.sessions.get(ctx.sessionId)?.child + if (child?.phase === 'starting') { + // A start that may never land is the one thing here Stop has to end; the chat stays. + await context.stopAgent(ctx.sessionId) return { ok: true, value: { turnId: params.turnId, cancelled: true } } } - return session?.hasProviderChild + return child ? plan.run(ctx) : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } } @@ -210,7 +211,12 @@ export function readStructuredAgentSessionOptions( if (!context.deps.adapter.readOptions) { throw new Error('structured_agent_session_options_unsupported') } - const options = await context.deps.adapter.readOptions({ sessionId, fence: session.fence }) + const options = await context.deps.adapter.readOptions({ + sessionId, + fence: + session.child?.fence ?? + structuredAgentSessionConversationFence(context.deps.store, sessionId) + }) return { ...options, rewind: @@ -244,6 +250,7 @@ export async function settleStructuredAgentSessionLateDispatch( if (!session) { return } + const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) // The journal queue drains before close; the host queue would defer this past teardown. await session.journal.resolveDispatch( 'providerIdentity' in input @@ -251,13 +258,13 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: input.clientMessageId, state: 'accepted', providerIdentity: input.providerIdentity, - fence: session.fence + fence } : { clientMessageId: input.clientMessageId, state: 'rejected', reason: input.reason, - fence: session.fence + fence } ) } @@ -275,7 +282,9 @@ export async function settleStructuredAgentSessionLateDispatch( * it never makes a send re-deliverable, because the provider may well have run it. */ export async function releaseStructuredAgentSessionUnansweredDispatches( - context: Pick, + context: Pick & { + deps: { store: Pick } + }, input: { sessionId: string; reason: string } ): Promise { const session = context.sessions.get(input.sessionId) @@ -294,7 +303,7 @@ export async function releaseStructuredAgentSessionUnansweredDispatches( state: 'unknown', // The earlier reason names a sharper fact than this one does. reason: entry.reason ?? input.reason, - fence: session.fence, + fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), recovered: true }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index fd2c911c74e5..84fa67b93317 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -26,25 +26,43 @@ export type StructuredAgentSessionReveal = { readable: boolean } +/** Which provider child: the adapter acquisition and the lease fence it writes at. */ +export type StructuredAgentSessionProviderChildIdentity = { + readonly generation: string | null + readonly fence: number +} + +/** The provider process behind a conversation. Written only in + * `structured-agent-session-provider-child`. */ +export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { + /** A publish-first acquire is `starting` until the adapter's `started` event; only then are its + * reported options fact. */ + phase: StructuredAgentSessionProviderChildPhase +} + +/** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a + * Stop from a failure. */ +export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChildIdentity & { + cause: 'exit' | 'attach-failed' | 'stop' | 'evict' + /** The provider's own diagnostic, when the ending carried one. */ + reason: string | null + duringStartup: boolean +} + +/** The conversation: its journal, params and readers outlive any child that serves it. */ export type StructuredAgentSessionHostSession = { /** Readonly: a new handle enters only through the session map's `set`, which binds its delivery. */ readonly journal: AgentSessionJournal params: AgentSessionAttachParams - fence: number - /** Whether THIS host generation is running the provider process behind the session. A journal - * restored for reading has none — so it may not be evicted to free a child, nor have its lease - * released as an observed exit. */ - hasProviderChild: boolean - /** Whether the child behind `hasProviderChild` has proven its start. A publish-first acquire - * is `starting` until the adapter's `started` event; only then are its reported options fact. */ - providerChildPhase: StructuredAgentSessionProviderChildPhase + /** The child THIS host generation runs for the conversation. A conversation opened for reading + * has none — so it may not be evicted to free a child, nor have its lease released as an + * observed exit. */ + child: StructuredAgentSessionProviderChild | null /** The wind-down this host still owes for a child it started: settling that generation's work - * and handing the lease back. A separate fact from `hasProviderChild`, which goes false the - * moment the adapter proves the exit — an eviction that aborts after that point must still be - * able to finish the wind-down on the next close. */ - owesProviderChildWindDown?: boolean - /** Exact adapter acquisition behind `hasProviderChild`; retained after exit to fence recovery. */ - acquisitionGeneration: string | null + * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the + * exit — an eviction that aborts after that point must still finish it on the next close. */ + owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity + lastEndedChild?: StructuredAgentSessionEndedChild } export type StructuredAgentSessionHostDeps = { 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 ba059204cb17..bfe626813bd7 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 @@ -20,6 +20,7 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { createStructuredAgentSessionHolds, evictHeldStructuredAgentSession, + stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { @@ -173,7 +174,14 @@ export class StructuredAgentSessionHost { runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), - forgetStatus: this.clientDelivery.forgetStatus + forgetStatus: this.clientDelivery.forgetStatus, + publishStatus: this.clientDelivery.publishStatus, + publishFence: (sessionId) => { + const journal = this.sessions.get(sessionId)?.journal + if (journal) { + this.subscribers.snapshot(sessionId, journal) + } + } } } @@ -261,7 +269,8 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopStartingChild: (sessionId) => this.closeUnderSerialize(sessionId), + stopAgent: (sessionId) => + stopStructuredAgentSessionAgentUnderSerialize(this.lifetimeContext(), sessionId), now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index ef08e1c7e93d..942c9ef61521 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -15,11 +15,7 @@ import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' -import { - evictStructuredAgentSession, - STRUCTURED_AGENT_SESSION_EVICTION_STEPS, - type StructuredAgentSessionEvictionContext -} from './structured-agent-session-eviction' +import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -73,30 +69,12 @@ function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSe return { journal, params: {} as StructuredAgentSessionHostSession['params'], - fence: 1, - hasProviderChild: false, - providerChildPhase: 'ready', - acquisitionGeneration: null + child: null } } -function evictionContext( - overrides: Partial -): StructuredAgentSessionEvictionContext { - return { - sessionId: SESSION, - hasProviderChild: false, - eventSink: { - drained: async () => ({ ok: true }) as const, - unbind: () => undefined, - close: () => undefined - } as unknown as StructuredAgentSessionEvictionContext['eventSink'], - adapter: {} as StructuredAgentSessionEvictionContext['adapter'], - forget: async () => undefined, - discardSink: () => undefined, - releaseLease: async () => undefined, - ...overrides - } +function forgetContext(sessions: Map) { + return { deps: { store: { getRecord: () => null } }, sessions, forgetStatus: () => undefined } } beforeEach(async () => { @@ -140,46 +118,37 @@ describe('site 6: recovery rehydration', () => { }) }) -describe('sites 9 and 10: the delete and overwrite callbacks', () => { +describe('sites 9 and 10: closing a conversation handle', () => { it('awaits the journal close before dropping the map entry', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir }) const sessions = new Map([[SESSION, hostSession(journal)]]) const order: string[] = [] + const close = journal.close.bind(journal) + journal.close = async () => { + order.push('close-started') + await close() + order.push(sessions.has(SESSION) ? 'closed' : 'dropped-before-close') + } - await evictStructuredAgentSession( - evictionContext({ - forget: async () => { - order.push('close-started') - await sessions.get(SESSION)?.journal.close() - order.push('closed') - sessions.delete(SESSION) - order.push('forgotten') - } - }), - STRUCTURED_AGENT_SESSION_EVICTION_STEPS - ) + await forgetStructuredAgentSession(forgetContext(sessions), SESSION) - expect(order).toEqual(['close-started', 'closed', 'forgotten']) + expect(order).toEqual(['close-started', 'closed']) expect(sessions.size).toBe(0) await expectNothingHoldsTheDirectory(journalDir) }) - it('aborts the eviction with the session still indexed when the close rejects', async () => { + it('keeps the session indexed when the close rejects', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir }) const sessions = new Map([[SESSION, hostSession(journal)]]) + const close = journal.close.bind(journal) + journal.close = () => Promise.reject(new Error('close rejected')) - await expect( - evictStructuredAgentSession( - evictionContext({ - forget: async () => { - await Promise.reject(new Error('close rejected')) - } - }), - STRUCTURED_AGENT_SESSION_EVICTION_STEPS - ) - ).rejects.toMatchObject({ step: 'forget-session' }) + await expect(forgetStructuredAgentSession(forgetContext(sessions), SESSION)).rejects.toThrow( + 'close rejected' + ) // Still indexed, so the next close is a real retry. expect(sessions.has(SESSION)).toBe(true) + journal.close = close }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts new file mode 100644 index 000000000000..3f21a8076739 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -0,0 +1,278 @@ +// The provider child is its own record on the conversation: stopping it, losing it or failing to +// start it ends the child, never the conversation. Against the real host, store and journal, with a +// live subscriber opened before each action. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let dispatch: Mock +let adapterExtras: Partial + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function generation(): string { + return `generation-${acquire.mock.calls.length}` +} + +const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: generation(), + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } +}) + +/** A Claude-shaped child: published at spawn, so it is `starting` until `started`. */ +const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const +}) + +function startHost(): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined), + ...adapterExtras + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + releaseGraceMs: 60_000, + now: () => NOW + }) +} + +async function restartHost(): Promise { + await host.flushAllStreamedEvents() + startHost() +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-child-record-')) + resetHostTestOperationIds() + adapterExtras = {} + acquire = vi.fn(spawnChild) + dispatch = vi.fn(async (input) => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${input.clientMessageId}`, + ordinal: dispatch.mock.calls.length + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + startHost() + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + await host.close(SESSION) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +async function accept(text: string): Promise { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + return clientOperationId +} + +function stop() { + const turnId = 'turn-none' + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: { turnId } + }) + }, + turnId + }) +} + +function submission(id: string): AgentJournalSubmission | undefined { + return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) +} + +function statusRows(): { text: string; tone?: string }[] { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'status' + ? [{ text: item.body.text, ...(item.body.tone ? { tone: item.body.tone } : {}) }] + : [] + ) +} + +function conversation() { + return host['sessions'].get(SESSION) +} + +function subscribe(): AgentSessionSubscribeEvent[] { + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: (event) => events.push(structuredClone(event)) + }) + return events +} + +/** The chat's status row as a session list sees it, frame by frame. */ +function watchStatus(): AgentSessionStatusSummary[] { + const frames: AgentSessionStatusSummary[] = [] + host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + frames.push(event.session) + } + } + }) + return frames +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('Stop on a child still proving its start', () => { + it('ends the child and keeps the conversation, its holders and its readers (R1)', async () => { + const ended = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => ended.promise), + closeSession: vi.fn(async () => { + ended.resolve() + return true + }) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + await host.hold(SESSION, 'surface-1', { resume: false }) + const first = await accept('hello') + const journal = conversation()?.journal + const events = subscribe() + const frames = watchStatus() + await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + // The same conversation: no reopen, the holder kept, and the chat told it is idle again. + expect(conversation()?.journal).toBe(journal) + expect(conversation()?.child).toBeNull() + expect(host.isHeld(SESSION)).toBe(true) + expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') + expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') + expect(submission(first)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + // A Stop is not a failure: no row, and the loop is gone. + expect(statusRows()).toEqual([]) + await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + + const next = await accept('after stop') + await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + expect(conversation()?.journal).toBe(journal) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) + // The reader opened before the Stop saw the next message delivered on the same stream. + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.submissions.some( + (entry) => entry.clientMessageId === next && entry.dispatchState === 'accepted' + ) + ) + ).toBe(true) + }) +}) + +describe('a settlement retry for an earlier child inside the attach for the next one', () => { + it('settles the earlier child and leaves the queued message to the new child (R1)', async () => { + // The earlier child's settlement is still owed; the lease is otherwise released. + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + settlementRetryRequired: true, + settlementRetryId: `provider-exit:${SESSION}:1:generation-1` + } + })) + const retryFence = store.getRecord(SESSION)!.lease.runtimeFence + const id = await accept('for the next child') + + await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + expect(store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined() + // Handed over at the new child's fence, which the attach reserved after the retry. + const newFence = store.getRecord(SESSION)!.lease.runtimeFence + expect(newFence).toBeGreaterThan(retryFence) + expect(submission(id)?.fence).toBe(newFence) + expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts new file mode 100644 index 000000000000..56be117bc4a9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -0,0 +1,66 @@ +// The provider child behind a conversation, kept as its own record on the conversation's entry. +// +// The entry is the conversation and outlives any number of children. A child enters only when an +// attach has fully succeeded, and leaves only through `endProviderChild`, which every ending shares: +// an exit, a failed re-attach, a Stop and an eviction. Each is matched on the child's generation +// and fence, so an ending that arrives late for an older child cannot end a newer one. + +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + StructuredAgentSessionEndedChild, + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' + +type ChildBearer = Pick + +/** The fence a conversation write carries: the record's, which is where the next child starts. A + * child's own writes carry `child.fence`, which equals it while that child holds the lease. */ +export function structuredAgentSessionConversationFence( + store: Pick, + sessionId: string +): number { + return store.getRecord(sessionId)?.lease.runtimeFence ?? 0 +} + +/** For the end of a successful attach only: a failed one never wrote a child to take back. */ +export function indexProviderChild( + session: ChildBearer, + child: StructuredAgentSessionProviderChild +): void { + session.child = child +} + +export function markProviderChildStarted( + session: ChildBearer, + identity: StructuredAgentSessionProviderChildIdentity +): boolean { + const child = matchingChild(session, identity) + if (child) { + child.phase = 'ready' + } + return child !== null +} + +export function endProviderChild( + session: ChildBearer, + ended: StructuredAgentSessionEndedChild +): boolean { + if (!matchingChild(session, ended)) { + return false + } + session.child = null + session.lastEndedChild = ended + return true +} + +function matchingChild( + session: ChildBearer, + identity: StructuredAgentSessionProviderChildIdentity +): StructuredAgentSessionProviderChild | null { + const { child } = session + return child && child.generation === identity.generation && child.fence === identity.fence + ? child + : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 55c718f967b9..6d6d910cc551 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -16,6 +16,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' +import { markProviderChildStarted } from './structured-agent-session-provider-child' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -35,13 +36,14 @@ export function settleStructuredAgentSessionProviderStarted( return context.serialize(event.sessionId, async () => { const session = context.sessions.get(event.sessionId) if ( - !session?.hasProviderChild || - session.fence !== event.fence || - session.acquisitionGeneration !== event.acquisitionGeneration + !session || + !markProviderChildStarted(session, { + generation: event.acquisitionGeneration, + fence: event.fence + }) ) { return } - session.providerChildPhase = 'ready' // Prompts held for the start are written now but open a turn only on their echo; a release // tick in between would stop the child before it runs them. context.restartReleaseGrace(event.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 index 21c3d9a5f7f5..514008e49f9b 100644 --- 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 @@ -87,7 +87,7 @@ describe('a session whose journal is still the pre-SQLite format', () => { .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) expect(disclosed.join('')).toContain(transcript) // Publishing it costs no agent process; acquisition still waits for the user. - expect(restored!.session.hasProviderChild).toBe(false) + expect(restored!.session.child).toBeNull() }) it('is published for a remnant whose log is gone', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index c025c0dc46af..87a56efb7b95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -44,7 +44,9 @@ export type StructuredAgentSessionContinuationOutcome = { /** The slice of the host one continuation needs. Structural so this module never imports the host. */ export type StructuredAgentSessionContinuationHost = { - sessions: ReadonlyMap + sessions: ReadonlyMap + /** The fence a conversation write carries; null when the session is not open. */ + conversationFence: (sessionId: string) => number | null send: (input: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem @@ -72,7 +74,7 @@ export function restartContinuationDeps( marker: AgentSessionResumeMarker ): StructuredAgentSessionContinuationDeps { return { - currentFence: (sessionId) => host.sessions.get(sessionId)?.fence ?? null, + currentFence: host.conversationFence, send: (input) => host.send({ ...input, @@ -107,17 +109,18 @@ export function noteRestartReattachFailed( /** Writes a host-authored status note into the chat. */ function restartNoteWriter( - host: Pick + host: Pick ): StructuredAgentSessionContinuationDeps['note'] { return async (sessionId, text, tone) => { const session = host.sessions.get(sessionId) - if (!session) { + const fence = host.conversationFence(sessionId) + if (!session || fence === null) { return } await session.journal.appendItem( { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, { kind: 'status', text, ...(tone ? { tone } : {}) }, - { fence: session.fence } + { fence } ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 41ae0a1b1a84..61e61debe14f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -37,8 +37,7 @@ describe('restart journal restoration', () => { session: { journal: {}, params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, - fence: 1, - hasProviderChild: false, + child: null, sessionId }, reset: null @@ -94,7 +93,7 @@ describe('restart journal restoration', () => { journal: {}, params, fence: 4, - hasProviderChild: false, + child: null, acquisitionGeneration: null }, reset: null @@ -132,7 +131,7 @@ describe('restart journal restoration', () => { journal: {}, params: {}, fence: 4, - hasProviderChild: false, + child: null, acquisitionGeneration: null }, reset: null 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 29453e74652b..0fa4ee8534d8 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 @@ -41,8 +41,9 @@ import { type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -type LiveSession = { journal: AgentSessionJournal; hasProviderChild: boolean; fence: number } +type LiveSession = { journal: AgentSessionJournal; child: { fence: number } | null } export type StructuredAgentSessionRestartResumeSurfaces = { revealSession: (sessionId: string) => Promise<{ readable: boolean }> @@ -152,6 +153,10 @@ export function createStructuredAgentSessionRestartResume( const continuationHost: StructuredAgentSessionContinuationHost = { ...surfaces, sessions, + conversationFence: (sessionId) => + sessions.has(sessionId) + ? structuredAgentSessionConversationFence(deps.store, sessionId) + : null, stillResumable: (marker, options) => derive([marker], 'may-be-held', options).candidates.length === 1 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index e5b65009634a..68823c078dcd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -145,7 +145,7 @@ export type HarnessJournal = { appendItem: (envelope: unknown, body: { kind: string; text: string }) => Promise } -export type HarnessSession = { journal: HarnessJournal; hasProviderChild: boolean; fence?: number } +export type HarnessSession = { journal: HarnessJournal; child: { fence: number } | null } export function journal( items: AgentJournalRenderItem[], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 1d6a8a1fc5ec..09a172f0b1d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -48,7 +48,7 @@ describe('deriving what was working at teardown', () => { it('marks a session this host was running a turn for', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -74,7 +74,7 @@ describe('deriving what was working at teardown', () => { it('carries the update trigger so the surface can say the restart was not the user choice', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -89,7 +89,7 @@ describe('deriving what was working at teardown', () => { it('marks nothing for an idle session', () => { expect( markersAtTeardown({ - sessions: new Map([[SESSION, { journal: journal([]), hasProviderChild: true }]]), + sessions: new Map([[SESSION, { journal: journal([]), child: { fence: 1 } }]]), getRecord: () => record(), backgroundTasks: () => undefined, trigger: 'quit', @@ -103,7 +103,7 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -115,12 +115,12 @@ describe('deriving what was working at teardown', () => { }) // The user's stated fear. A journal restored for READING carries whatever `running` row an older - // crash left behind, and it is the live `hasProviderChild` — not that row — that decides. + // crash left behind, and it is the live `child` — not that row — that decides. it('marks nothing for a stale running row this host was not executing', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: false }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: null }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -141,7 +141,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-1', 'running'), pendingApproval()]), - hasProviderChild: true + child: { fence: 1 } } ] ]), @@ -165,7 +165,7 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose subagent was still running, anchored on its last turn', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [ @@ -188,7 +188,7 @@ describe('deriving what was working at teardown', () => { it('records only the live rows of the roster, bounded', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [ @@ -213,7 +213,7 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose only live work is a monitor', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [{ id: 'task-m', kind: 'monitor', name: 'ci-watch' }], @@ -230,7 +230,7 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [ @@ -248,7 +248,7 @@ describe('deriving what was working at teardown', () => { it('records the identity root so an advancing Claude leaf cannot invalidate the marker', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => claudeRecord(null), backgroundTasks: () => undefined, @@ -264,7 +264,7 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record({ chain: [] }), backgroundTasks: () => undefined, @@ -285,7 +285,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([], false, [submission('msg-1', 'pending')]), - hasProviderChild: true + child: { fence: 1 } } ] ]), @@ -310,7 +310,7 @@ describe('deriving what was working at teardown', () => { journal: journal([turnItem('turn-0', 'completed')], false, [ submission('msg-1', 'pending') ]), - hasProviderChild: true + child: { fence: 1 } } ] ]), @@ -335,7 +335,7 @@ describe('deriving what was working at teardown', () => { journal: journal([turnItem('turn-1', 'running')], false, [ submission('msg-1', 'accepted') ]), - hasProviderChild: true + child: { fence: 1 } } ] ]), 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 318f2192cc28..8bf3bc2242c9 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 @@ -70,8 +70,14 @@ export function createStructuredAgentSessionHostRestore( const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - retrySettlement: (sessionId, params) => - retryPendingStructuredAgentSessionSettlement({ deps, sessions, sessionId, params, now }), + // Runs once the restorer indexed the conversation, so it retries into that one handle. + retrySettlement: (sessionId) => + retryPendingStructuredAgentSessionSettlement({ + deps, + sessionId, + openJournal: async () => sessions.get(sessionId)?.journal ?? null, + now + }), ...wiring }) const gate = new StructuredAgentSessionRestartRestoreGate() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts index 976e9417628a..4ea4b4e13a30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts @@ -45,7 +45,7 @@ function retry(settlementId: string, deathEvidence: AgentSessionDeathEvidence) { return retryLoadedStructuredAgentSessionSettlement({ deps: { store: store() }, sessionId: SESSION, - session: { journal, fence: FENCE, acquisitionGeneration: null }, + journal, now: () => 2_000 }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts index 1000881220e9..8d19420feb43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts @@ -1,60 +1,43 @@ -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { attachJournal } from './structured-agent-session-attach' -import type { - StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession -} from './structured-agent-session-host-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' import { turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' import { captureUnfinishedStructuredAgentSessionWork, settleStructuredAgentSessionDeadGeneration, - unfinishedStructuredAgentSessionWorkWasInterrupted + unfinishedStructuredAgentSessionWorkWasInterrupted, + type DeadGenerationJournal } from './structured-agent-session-dead-generation-settlement' +/** Retries a durable provider-exit settlement against the conversation's own journal. The retry is + * for an earlier child, so it writes at the record's fence and leaves the conversation — and any + * message queued for the next child — alone. */ export async function retryPendingStructuredAgentSessionSettlement(input: { deps: StructuredAgentSessionHostDeps - sessions: Map sessionId: string - params: AgentSessionAttachParams + /** The conversation's journal, opened through the host's one open when it is closed. */ + openJournal: () => Promise now: () => number }): Promise { const record = input.deps.store.getRecord(input.sessionId) if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { return true } - let journal = input.sessions.get(input.sessionId)?.journal - if (!journal) { - try { - journal = ( - await attachJournal({ - record, - params: input.params, - journalRoot: input.deps.journalRoot, - adapter: input.deps.adapter - }) - ).journal - } catch (error) { - input.deps.onEventSinkError?.({ sessionId: input.sessionId, error }) - return false - } + let journal: AgentSessionJournal | null + try { + journal = await input.openJournal() + } catch (error) { + input.deps.onEventSinkError?.({ sessionId: input.sessionId, error }) + return false } - const current = input.sessions.get(input.sessionId) - const retrySession = - current ?? - ({ - journal, - params: input.params, - fence: record.lease.runtimeFence, - hasProviderChild: false, - acquisitionGeneration: null - } as StructuredAgentSessionHostSession) - return retryLoadedStructuredAgentSessionSettlement({ - deps: input.deps, - sessionId: input.sessionId, - session: retrySession, - now: input.now - }) + return journal + ? retryLoadedStructuredAgentSessionSettlement({ + deps: input.deps, + sessionId: input.sessionId, + journal, + now: input.now + }) + : false } export async function retryLoadedStructuredAgentSessionSettlement(input: { @@ -63,23 +46,23 @@ export async function retryLoadedStructuredAgentSessionSettlement(input: { onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] } sessionId: string - session: Pick + journal: DeadGenerationJournal now: () => number }): Promise { const record = input.deps.store.getRecord(input.sessionId) if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { return true } - const retrySession = input.session - retrySession.fence = record.lease.runtimeFence + const { journal } = input + const fence = record.lease.runtimeFence const onError = (id: string, error: unknown): void => input.deps.onEventSinkError?.({ sessionId: id, error }) // Only an observed exit earns an end time; a probe-proven death never saw one. const verdict = turnVerdictFromDeathEvidence(record.lease.deathEvidence) const ok = await settleStructuredAgentSessionDeadGeneration({ - journal: retrySession.journal, + journal, sessionId: input.sessionId, - fence: retrySession.fence, + fence, settlementId: record.lease.settlementRetryId, pendingSubmissionReason: 'provider_exited_before_acknowledgement', verdict, @@ -90,8 +73,8 @@ export async function retryLoadedStructuredAgentSessionSettlement(input: { showUnexpectedExitOutcome: verdict.state === 'interrupted' && unfinishedStructuredAgentSessionWorkWasInterrupted( - captureUnfinishedStructuredAgentSessionWork(retrySession.journal), - retrySession.journal, + captureUnfinishedStructuredAgentSessionWork(journal), + journal, verdict.completedAt ), ...(record.lease.deathEvidence?.detail diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 516122c091c2..52a1d02f7c29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -9,7 +9,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi * is still queued: a start whose messages Stop withdrew did not fail anyone. */ export async function recordStructuredAgentSessionStartFailure( - session: Pick, + session: Pick & { fence: number }, text: string ): Promise { const oldest = oldestQueuedSubmission(session) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index c7f61d5c9ae7..c8b5b1f40c5a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -19,9 +19,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { journal: { appendLifecycleBatch: ReturnType } } { return { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), @@ -105,7 +103,10 @@ describe('a provider that ends before it finished starting', () => { }) it("reads a start that failed off the host's own phase when the provider omits the flag", async () => { - const session = { ...startedSession(), providerChildPhase: 'starting' as const } + const session = { + ...startedSession(), + child: { generation: GENERATION, fence: 7, phase: 'starting' as const } + } const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts index e4fa6ebed1bf..c130aaf9d597 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts @@ -2,17 +2,11 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store export function indexedStatusFeedSession(session: { journal: AgentSessionJournal - hasProviderChild?: boolean - providerChildPhase?: 'starting' | 'ready' - fence?: number + child?: { phase: 'starting' | 'ready' } | null }) { return { journal: session.journal, - fence: session.fence ?? 1, - ...(session.hasProviderChild !== undefined - ? { hasProviderChild: session.hasProviderChild } - : {}), - ...(session.providerChildPhase ? { providerChildPhase: session.providerChildPhase } : {}), + ...(session.child !== undefined ? { child: session.child } : {}), params: { location: { executionHostId: 'local' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 65c7f54fb305..2c2863707c86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentSessionBackgroundTask, AgentSessionStatusEvent, @@ -33,6 +34,8 @@ const USER_IDENTITY = { ordinal: 1 } as const +type Indexed = Parameters[0] + let root: string const journals = createTrackedJournalOpener() @@ -82,7 +85,9 @@ function feedFor( } } } as unknown as ReadonlyMap>, - getRecord: () => record as AgentSessionRecord | null, + // A partial record still has a lease: the feed reads the conversation's fence off it. + getRecord: () => + record && ({ lease: agentSessionRecordFixture().lease, ...record } as AgentSessionRecord), now: () => (now += 1) }) const events: AgentSessionStatusEvent[] = [] @@ -93,17 +98,17 @@ function feedFor( describe('StructuredAgentSessionStatusFeed', () => { it('projects whether the owned child has proven its start, and nothing once it is not owned', async () => { const journal = await openJournal() - const session = { journal, hasProviderChild: true, providerChildPhase: 'starting' as const } + const session = { journal, child: { phase: 'starting' as const } } const sessions = new Map[0]>([[SESSION, session]]) const { feed, events, dispose } = feedFor(sessions) expect(events.at(-1)).toMatchObject({ type: 'snapshot', sessions: [{ hostExecutionOwned: true, hostExecutionPhase: 'starting' }] }) - sessions.set(SESSION, { ...session, providerChildPhase: 'ready' }) + sessions.set(SESSION, { ...session, child: { phase: 'ready' } }) feed.publish(SESSION, journal) expect(events.at(-1)).toMatchObject({ session: { hostExecutionPhase: 'ready' } }) - sessions.set(SESSION, { ...session, hasProviderChild: false }) + sessions.set(SESSION, { ...session, child: null }) feed.publish(SESSION, journal) expect(events.at(-1)).not.toMatchObject({ session: { hostExecutionPhase: expect.any(String) } }) dispose() @@ -111,7 +116,7 @@ describe('StructuredAgentSessionStatusFeed', () => { it('publishes provider ownership transitions without changing journal time', async () => { const journal = await openJournal() - const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]]) + const sessions = new Map([[SESSION, { journal, child: { phase: 'ready' } }]]) const { feed, events, dispose } = feedFor(sessions) events.length = 0 await journal.appendItem( @@ -130,7 +135,7 @@ describe('StructuredAgentSessionStatusFeed', () => { throw new Error('status publication missing') } const journalTime = firstStatus.session.updatedAt - sessions.get(SESSION)!.hasProviderChild = false + sessions.get(SESSION)!.child = null feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ type: 'status', @@ -167,8 +172,10 @@ describe('StructuredAgentSessionStatusFeed', () => { it('stops projecting an old-host unknown submission after the owner fence advances', async () => { const journal = await openJournal() - const session = { journal, fence: 1 } - const { feed, events } = feedFor(new Map([[SESSION, session]])) + // The conversation's fence is the record's: a child's end moves it. + const lease = agentSessionRecordFixture().lease + const record = agentSessionRecordFixture({ ...lease, runtimeFence: 1 }) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) await journal.appendSubmission({ clientMessageId: 'old-host', payloadFingerprint: 'fp', @@ -183,7 +190,7 @@ describe('StructuredAgentSessionStatusFeed', () => { }) feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ session: { status: 'working' } }) - session.fence = 2 + record.lease.runtimeFence = 2 feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } }) }) @@ -793,7 +800,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks it('receives every change once, ownership revocation, and the forget edge', async () => { const journal = await openJournal() - const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]]) + const sessions = new Map([[SESSION, { journal, child: { phase: 'ready' } }]]) const { sink, published, forgotten } = sinkFor() const { feed } = feedFor(sessions, null, undefined, undefined, sink) await journal.appendItem( 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 29bf29a00f17..5403ec16abe3 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 @@ -41,9 +41,7 @@ export type StructuredAgentSessionStatusSubscriber = { type StatusFeedSession = { journal: AgentSessionJournal params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } - hasProviderChild?: boolean - providerChildPhase?: StructuredAgentSessionProviderChildPhase - fence?: number + child?: { phase: StructuredAgentSessionProviderChildPhase } | null } export type StructuredAgentSessionStatusFeedDeps = { @@ -238,7 +236,9 @@ export class StructuredAgentSessionStatusFeed { // An unreadable journal projects as "no turn": the chat itself shows the reset. const cursor = journal.cursor() const readOnly = journal.isReadOnly - const fence = session.fence + const record = this.deps.getRecord(sessionId) + // The conversation's fence, which a child's end moves: its unanswered sends stop counting. + const fence = record?.lease.runtimeFence let projection = this.journalProjections.get(journal) if ( !projection || @@ -262,7 +262,6 @@ export class StructuredAgentSessionStatusFeed { } this.journalProjections.set(journal, projection) } - const record = this.deps.getRecord(sessionId) const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. @@ -277,13 +276,8 @@ export class StructuredAgentSessionStatusFeed { sessionId, workspaceId: session.params.location.workspaceId, agent: session.params.provider, - ...(session.hasProviderChild - ? { - hostExecutionOwned: true as const, - ...(session.providerChildPhase - ? { hostExecutionPhase: session.providerChildPhase } - : {}) - } + ...(session.child + ? { hostExecutionOwned: true as const, hostExecutionPhase: session.child.phase } : {}), ...projection.summary, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index cf9089326671..4053988233ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -63,7 +63,9 @@ async function openSession() { const roster: { tasks: AgentSessionBackgroundTask[] } = { tasks: [] } const server = new AgentHookServer() const feed = new StructuredAgentSessionStatusFeed({ - sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, hasProviderChild: true })]]), + sessions: new Map([ + [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] + ]), getRecord: () => null, now: () => 1, readBackgroundTasks: () => ({ state: 'monitoring', tasks: roster.tasks }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index c8b8839318ff..1e0344a7b074 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -287,12 +287,10 @@ describe('a chat that closes', () => { }) .mockImplementation(closeJournal) - await expect(host.close(SESSION)).rejects.toMatchObject({ - step: 'forget-session', - cause: expect.objectContaining({ message: 'journal close result lost' }) - }) + // The child stopped and its lease went back; only the conversation's close is left to retry. + await expect(host.close(SESSION)).rejects.toThrow('journal close result lost') expect(host.hasSession(SESSION)).toBe(true) - expect(host['sessions'].get(SESSION)?.hasProviderChild).toBe(false) + expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null @@ -330,7 +328,7 @@ describe('a chat that closes', () => { await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) // The child is proven gone, but the wind-down it owes is not done: nothing settled, no release. - expect(session!.hasProviderChild).toBe(false) + expect(session!.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') await expect(host.close(SESSION)).resolves.toBeUndefined() @@ -784,7 +782,7 @@ describe('a quit over an eviction that never got its retry', () => { failNextDrain() await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) - expect(host['sessions'].get(SESSION)?.hasProviderChild).toBe(false) + expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index 79f21a349b42..952fa70657d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -32,10 +32,12 @@ function contextWith(submissions: AgentJournalSubmission[]) { return { epoch: 'e', sequence: 1 } }) } - // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and `fence`. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the host session is unreachable from this mutation. - const session = { journal, fence: FENCE } as unknown as ReleaseSession - const context: ReleaseContext = { sessions: new Map([['s-1', session]]) } + // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and the record fence. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the session and the record is unreachable from this mutation. + const context = { + sessions: new Map([['s-1', { journal }]]), + deps: { store: { getRecord: () => ({ lease: { runtimeFence: FENCE } }) } } + } as unknown as ReleaseContext return { context, resolved, journal } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index bba6154af437..5aae86f32910 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -33,9 +33,14 @@ function recoveryContext(input: { resumeCapable?: boolean }) { const session = { - hasProviderChild: false, - fence: 8, - acquisitionGeneration: input.generation ?? GENERATION + child: null, + lastEndedChild: { + generation: input.generation ?? GENERATION, + fence: 7, + cause: 'exit', + reason: null, + duringStartup: false + } } as StructuredAgentSessionHostSession const record = { lease: { @@ -128,9 +133,7 @@ describe('provider-exit recovery tickets', () => { .mockRejectedValueOnce(new Error('journal unavailable')) .mockResolvedValue({ epoch: 'epoch-1', sequence: 2 }) const session = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] @@ -174,7 +177,7 @@ describe('provider-exit recovery tickets', () => { retryLoadedStructuredAgentSessionSettlement({ deps: { store } as never, sessionId: SESSION, - session: { journal: session.journal, fence: 8, acquisitionGeneration: null }, + journal: session.journal, now: () => now }) ).resolves.toBe(true) @@ -199,9 +202,7 @@ describe('provider-exit recovery tickets', () => { lifecycleItem('turn-2', 2, { state: 'running', startedAt: 30 }) ] const session = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { snapshot: () => ({ items }), appendLifecycleBatch, @@ -250,7 +251,7 @@ describe('provider-exit recovery tickets', () => { 7, 'provider_exited_before_acknowledgement' ) - expect(session.hasProviderChild).toBe(false) + expect(session.child).toBeNull() // The running row is revised to interrupted at exit receipt, never tombstoned. expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ settlementId: `dead-generation:provider-exit:${SESSION}:7:${GENERATION}`, @@ -307,9 +308,7 @@ describe('provider-exit recovery tickets', () => { sequence: 3 })) const session: StructuredAgentSessionUnexpectedExitSession = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { snapshot: () => ({ items }), appendLifecycleBatch, @@ -365,9 +364,7 @@ describe('provider-exit recovery tickets', () => { it('settles a submission the dead child never acknowledged', async () => { const markPendingSubmissionsUnknown = vi.fn(async () => ['client-1']) const session: StructuredAgentSessionUnexpectedExitSession = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), @@ -414,9 +411,7 @@ describe('provider-exit recovery tickets', () => { it('does not release or reacquire while terminal settlement retry is still failing', async () => { const session: StructuredAgentSessionUnexpectedExitSession = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), @@ -453,8 +448,7 @@ describe('provider-exit recovery tickets', () => { const result = await settleUnexpectedStructuredAgentSessionExit(context, event) expect(result).toBeNull() - expect(session.hasProviderChild).toBe(false) - expect(session.fence).toBe(8) + expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) expect(release).toHaveBeenCalledTimes(2) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 7e7148383111..212963b85398 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,8 +1,6 @@ -import type { - StructuredAgentSessionEndedEvent, - StructuredAgentSessionProviderChildPhase -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { endProviderChild } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit, type StructuredAgentSessionLeaseStore @@ -30,13 +28,10 @@ export type StructuredAgentSessionRecoveryTicket = { stableSettlementId: string } -export type StructuredAgentSessionUnexpectedExitSession = { - journal: DeadGenerationJournal - hasProviderChild: boolean - fence: number - acquisitionGeneration: string | null - providerChildPhase?: StructuredAgentSessionProviderChildPhase -} +export type StructuredAgentSessionUnexpectedExitSession = Pick< + StructuredAgentSessionHostSession, + 'child' | 'lastEndedChild' +> & { journal: DeadGenerationJournal } export type StructuredAgentSessionUnexpectedExitContext< TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession @@ -66,24 +61,35 @@ export async function settleUnexpectedStructuredAgentSessionExit< const observedAt = event.observedAt ?? context.now() return context.serialize(unexpectedEvent.sessionId, async () => { const session = context.sessions.get(unexpectedEvent.sessionId) + const child = session?.child if ( - !session?.hasProviderChild || - session.fence !== unexpectedEvent.fence || - session.acquisitionGeneration !== unexpectedEvent.acquisitionGeneration + !session || + !child || + child.fence !== unexpectedEvent.fence || + child.generation !== unexpectedEvent.acquisitionGeneration ) { return null } + // The host's own phase decides, so a provider that omits the flag still gets a start that + // failed told as one: the row says so, and nothing resumes into the same failure. + const exitedDuringStartup = + unexpectedEvent.startupUnproven === true || child.phase === 'starting' + const endChild = (): void => { + endProviderChild(session, { + generation: child.generation, + fence: child.fence, + cause: 'exit', + reason: unexpectedEvent.reason, + duringStartup: exitedDuringStartup + }) + context.publishStatus?.(unexpectedEvent.sessionId) + } const record = context.store.getRecord(unexpectedEvent.sessionId) if (!record || record.lease.handoffStage !== null) { // An acquisition or recovery already owns this lease's transition. - session.hasProviderChild = false - context.publishStatus?.(unexpectedEvent.sessionId) + endChild() return null } - // The host's own phase decides, so a provider that omits the flag still gets a start that - // failed told as one: the row says so, and nothing resumes into the same failure. - const exitedDuringStartup = - unexpectedEvent.startupUnproven === true || session.providerChildPhase === 'starting' let settlementFailed = false const stableSettlementId = providerExitSettlementId(unexpectedEvent) @@ -103,7 +109,8 @@ export async function settleUnexpectedStructuredAgentSessionExit< settlementFailed = !(await retryUnexpectedExitSettlement({ context, event: unexpectedEvent, - session, + journal: session.journal, + fence: child.fence, stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, @@ -125,7 +132,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< sessionId: unexpectedEvent.sessionId, expectedFence: unexpectedEvent.fence, expectedAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, - acquisitionGeneration: session.acquisitionGeneration, + acquisitionGeneration: child.generation, now: context.now(), exitObservedAt: observedAt, ...(settlementFailed @@ -141,10 +148,8 @@ export async function settleUnexpectedStructuredAgentSessionExit< } catch (error) { context.onBarrierError?.(unexpectedEvent.sessionId, error) } finally { - session.hasProviderChild = false - context.publishStatus?.(unexpectedEvent.sessionId) + endChild() if (released) { - session.fence = released.lease.runtimeFence context.publishFence(unexpectedEvent.sessionId, session) } } @@ -170,10 +175,7 @@ export function isStructuredAgentSessionRecoveryTicketCurrent( store: Pick sessions: Map< string, - Pick< - StructuredAgentSessionUnexpectedExitSession, - 'hasProviderChild' | 'fence' | 'acquisitionGeneration' - > + Pick > hasResumeCapableHolder: (sessionId: string) => boolean }, @@ -182,9 +184,9 @@ export function isStructuredAgentSessionRecoveryTicketCurrent( const session = context.sessions.get(ticket.sessionId) const record = context.store.getRecord(ticket.sessionId) return ( - session?.hasProviderChild === false && - session.fence === ticket.releasedFence && - session.acquisitionGeneration === ticket.deadAcquisitionGeneration && + session !== undefined && + session.child === null && + session.lastEndedChild?.generation === ticket.deadAcquisitionGeneration && record?.lease.runtimeFence === ticket.releasedFence && record.lease.claimStatus === 'released' && record.lease.handoffStage === null && @@ -195,16 +197,17 @@ export function isStructuredAgentSessionRecoveryTicketCurrent( async function retryUnexpectedExitSettlement(input: { context: Pick event: UnexpectedExitLifecycleEvent - session: Pick + journal: DeadGenerationJournal + fence: number stableSettlementId: string verdict: StructuredAgentSessionTurnVerdict exitedDuringStartup: boolean showUnexpectedExitOutcome?: boolean }): Promise { return settleStructuredAgentSessionDeadGeneration({ - journal: input.session.journal, + journal: input.journal, sessionId: input.event.sessionId, - fence: input.session.fence, + fence: input.fence, settlementId: input.stableSettlementId, verdict: input.verdict, pendingSubmissionReason: 'provider_exited_before_acknowledgement', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 5a3c2497c348..820f4c3c95da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -138,8 +138,7 @@ function liveTasks( type WorkingCandidateSession = { journal: AgentSessionJournal /** Only this host generation's own child counts. A restored-for-reading journal has none. */ - hasProviderChild: boolean - fence?: number + child: { fence: number } | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -157,12 +156,12 @@ export function structuredAgentSessionWorkingAtStop(input: { }): AgentSessionResumeMarker | null { const { sessionId, session } = input // A journal this host cannot read tells us nothing about what the turn was doing. - if (!session?.hasProviderChild || session.journal.isReadOnly) { + if (!session?.child || session.journal.isReadOnly) { return null } const snapshot = session.journal.snapshot() const roster = input.backgroundTasks(sessionId) - const status = structuredAgentSessionShownStatus(snapshot, roster, session.fence) + const status = structuredAgentSessionShownStatus(snapshot, roster, session.child.fence) if (status.state === 'done') { return null } diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index b1e43a9025d2..27d5ba48b299 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -89,7 +89,7 @@ async function awaitingApproval() { SESSION, { journal, - hasProviderChild: true, + child: { phase: 'ready' as const }, params: { location: { executionHostId: 'local' as const, diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index b9210bf79e87..71bb0fddce18 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -143,7 +143,9 @@ async function openHost() { journalDir: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ - sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, hasProviderChild: true })]]), + sessions: new Map([ + [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] + ]), getRecord: () => null, now: () => 1, readBackgroundTasks: () => ({ state: 'monitoring', tasks: [] }) From aa56b5033c347ff522f6b7e5260b12a297dea4c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:47:10 -0700 Subject: [PATCH 041/101] fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. --- .../claude-structured-session-adapter.ts | 18 +- .../journal-pending-submission-recovery.ts | 8 +- ...-agent-session-accept-then-deliver.test.ts | 8 +- ...structured-agent-session-adapter-router.ts | 2 +- .../structured-agent-session-adapter.ts | 5 +- ...ured-agent-session-append-delivery.test.ts | 14 +- ...uctured-agent-session-conversation-open.ts | 28 +-- ...session-dead-generation-settlement.test.ts | 2 +- ...gent-session-dead-generation-settlement.ts | 45 ++-- .../structured-agent-session-delivery-loop.ts | 110 +++++++-- .../structured-agent-session-host-delivery.ts | 2 + .../structured-agent-session-host-lifetime.ts | 38 ++-- .../structured-agent-session-host-teardown.ts | 14 +- .../structured-agent-session-host.ts | 2 + ...gent-session-provider-child-record.test.ts | 215 +++++++++++++++++- ...uctured-agent-session-start-failure-row.ts | 41 ++-- ...agent-session-startup-failure-exit.test.ts | 11 +- ...ructured-agent-session-status-feed.test.ts | 3 +- ...ured-agent-session-unexpected-exit.test.ts | 17 +- ...tructured-agent-session-unexpected-exit.ts | 9 +- 20 files changed, 439 insertions(+), 153 deletions(-) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 0b8acadf303f..6e90d1547bdf 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -12,7 +12,10 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' +import { + claudeStartupFailureReason, + claudeStartupSettledWithin +} from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, @@ -114,9 +117,16 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) - /** Resolves once a published session's startup has landed, faulted, or been ended by a close. */ - awaitStarted = (sessionId: string): Promise => - this.sessions.get(sessionId)?.startup.settled ?? Promise.resolve() + /** Resolves once a published session's startup has landed, faulted, or been ended by a close; + * with the reason when it did not land. */ + awaitStarted = async (sessionId: string): Promise => { + const session = this.sessions.get(sessionId) + if (!session) { + return + } + await session.startup.settled + return claudeStartupFailureReason(session) ?? undefined + } /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ providerHistoryWindow: NonNullable = ( diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index 10f6821fcc53..c356d7904b23 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -35,7 +35,8 @@ export async function markJournalPendingSubmissionsUnknown( } /** Settles every submission a child that never proved its start left unanswered as `rejected`: - * such a child accepted nothing, so each is provably unwritten and safe to send again. */ + * such a child accepted nothing, so each is provably unwritten and safe to send again. A queued + * submission was never handed to that child; the delivery loop settles it. */ export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, @@ -45,8 +46,9 @@ export async function rejectJournalPendingSubmissions( .submissions() .filter( (entry) => - entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true) + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) ) for (const entry of unwritten) { await journal.resolveDispatch({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 91792f5875b7..186127847a6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -16,7 +16,7 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { AgentSessionPreSpawnError, @@ -488,15 +488,13 @@ describe('a child that exits before its message is handed over', () => { }) describe('Stop withdraws what is queued', () => { - it('withdraws a crash leftover the open could not settle, ahead of any delivery step (W17a)', async () => { + it('withdraws a crash leftover ahead of any delivery step (W17a)', async () => { await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('leftover', 'l', true), fence }) }) - const settle = vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') - settle.mockRejectedValueOnce(new Error('journal busy')) + // Stop's own open wakes the delivery loop, whose first step queues behind this Stop. expect(await stop()).toMatchObject({ ok: true }) - settle.mockRestore() expect(submission('leftover')).toMatchObject({ dispatchState: 'rejected', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 4a8a187becdc..566e41a66359 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -122,7 +122,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - awaitStarted = (sessionId: string): Promise => + awaitStarted = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() readOptions = (input: { sessionId: string; fence: number }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 0c44a31bbe4b..d261628956ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -279,8 +279,9 @@ export type StructuredAgentSessionAdapter = { /** Resolves once a live session can take an option write, or after a bound; never rejects. */ awaitOptionWritable?(sessionId: string): Promise /** Resolves once a session published before it proved its start has proven it, failed, or been - * closed; at once for any other. Never rejects. */ - awaitStarted?(sessionId: string): Promise + * closed; at once for any other. A start that did not land resolves with the chat's words for + * why. Never rejects. */ + awaitStarted?(sessionId: string): Promise readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index c8c908aae70c..9f38c85755f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -150,11 +150,15 @@ describe('an open chat receives every row its journal commits', () => { await exitBeforeProof() - expect(pane.received().statuses).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) - ]) - expect(pane.received().submissions).toContainEqual( - expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) + // The exit ends the child; the delivery loop, which reads why, rejects what it had queued. + await vi.waitFor(() => + expect(pane.received().submissions).toContainEqual( + expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) + ) + ) + // One row, however many of its writers reported the start. + expect(new Set(pane.received().statuses)).toEqual( + new Set([expect.stringMatching(/stopped before it finished starting: .*not signed in/)]) ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index eb850e1941f4..ceaa951e4d75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -1,16 +1,14 @@ // The one way a conversation's journal becomes open on this host: for a send, for a reader, and // for an attach that finds none open. // -// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it settles -// what an earlier host process left unanswered — the crash boundary. That needs no lease: a row -// queued there was accepted by a process that ended before handing it over, so it is provably -// unwritten; a handed-over row is only doubt, and provider history decides it later, under a won -// lease, in the attach. Nothing here starts a provider child. +// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it marks what +// an earlier host process handed over and left unanswered as in doubt — the crash boundary. That +// needs no lease: provider history decides such a row later, under a won lease, in the attach. A +// row an earlier process accepted and never handed over is the delivery loop's, which the open +// wakes. Nothing here starts a provider child. import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -90,9 +88,10 @@ export async function openStructuredAgentSessionConversationJournal( historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null }) try { - await settleEarlierProcessSubmissions(opened.journal, fence) + // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over + // one is only doubt, which provider history decides under a won lease. + await opened.journal.markPendingSubmissionsUnknown(fence) } catch (error) { - // Best effort: the delivery loop rejects a leftover queued row itself. deps.onEventSinkError?.({ sessionId, error }) } return { @@ -101,17 +100,6 @@ export async function openStructuredAgentSessionConversationJournal( } } -async function settleEarlierProcessSubmissions( - journal: AgentSessionJournal, - fence: number -): Promise { - // A handle closes only with nothing queued, so a queued row found here is a leftover. - await journal.rejectQueuedSubmissions(fence, DISPATCH_REJECTED_HOST_RESTARTED, (submission) => - journal.wroteBeforeOpen(submission.acceptedSequence) - ) - await journal.markPendingSubmissionsUnknown(fence) -} - export function attachParamsForRecord( record: AgentSessionRecord, input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 6d7d8618ad43..8ad51871c240 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -277,7 +277,7 @@ describe('dead structured-session generation settlement', () => { pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, unexpectedExitReason: 'claude stream-json exited (code 1): not signed in', - exitedDuringStartup: true + exitedDuringStartup: { generation: 'generation-1' } }) const reason = diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 0675d8730a6b..f79cbfcbc6bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -4,7 +4,6 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { @@ -12,6 +11,7 @@ import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' import { runningTurnLifecycleRevisions, type StructuredAgentSessionTurnVerdict @@ -88,7 +88,6 @@ export type DeadGenerationJournal = { appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - rejectQueuedSubmissions: AgentSessionJournal['rejectQueuedSubmissions'] snapshot: () => Pick, 'items'> pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] submissions?: () => DeadGenerationSubmission[] @@ -152,31 +151,21 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { showUnexpectedExitOutcome?: boolean /** Why the provider stopped, when the host has it. Rendered with the outcome copy. */ unexpectedExitReason?: string - /** The provider never finished starting; the outcome says so instead of naming a response. */ - exitedDuringStartup?: boolean - /** Why messages queued for delivery are rejected: given only when the child they were queued - * for is the one that ended. A retry for an earlier generation leaves them to the next child. */ - queuedRejection?: string + /** The provider never finished starting: the start that failed, keyed by the child's + * generation. Its row is the one the delivery loop writes for the same start. */ + exitedDuringStartup?: { generation: string | null } onError?: (sessionId: string, error: unknown) => void }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork - // Settled even with nothing else in flight: left queued, delivery would start another child - // for them, and one that dies the same way would start another. - const rejectsQueued = - input.queuedRejection !== undefined && - input.journal.submissions?.().some(isQueuedAgentJournalSubmission) === true - if (!showUnexpectedExitOutcome && !hasUnfinishedWork && !rejectsQueued) { + if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return true } - // A queued message was never handed to this child, so it is provably unwritten. A child - // that never proved its start accepted nothing either — input is written only after it - // initializes — so every send it left unanswered is rejected with the child's own diagnostic. - // A proven child's handed-over sends stay in doubt. - if (input.queuedRejection !== undefined) { - await input.journal.rejectQueuedSubmissions(input.fence, input.queuedRejection) - } + // A queued message is the delivery loop's to settle: it was never handed to this child. A + // child that never proved its start accepted nothing either — input is written only after it + // initializes — so every send it was handed is rejected with the child's own diagnostic. A + // proven child's handed-over sends stay in doubt. await (input.exitedDuringStartup ? input.journal.rejectPendingSubmissions( input.fence, @@ -185,17 +174,21 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome) { + if (showUnexpectedExitOutcome && input.exitedDuringStartup) { + // Until views stop starting children, a start can die with nothing queued for the loop. + mutations.push( + structuredAgentSessionStartFailureRow( + input.exitedDuringStartup.generation ?? input.settlementId, + providerStartupFailureOutcome(input.unexpectedExitReason) + ) + ) + } else if (showUnexpectedExitOutcome) { mutations.push({ kind: 'item', identity: { provider: 'orca', clientMessageId: input.settlementId }, body: { kind: 'status', - text: boundJournalStatusText( - input.exitedDuringStartup - ? providerStartupFailureOutcome(input.unexpectedExitReason) - : unexpectedProviderExitOutcome(input.unexpectedExitReason) - ) + text: boundJournalStatusText(unexpectedProviderExitOutcome(input.unexpectedExitReason)) } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 9d5246339595..3ea91214ad03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -1,17 +1,27 @@ -// The one thing that starts a provider child for a send, and the one thing that hands a message -// to it. +// The one thing that starts a provider child for a send, the one thing that hands a message to +// it, and the one thing that settles a queued message because of a start, a child or a leftover. // // A send is accepted on its own serialized step and returns; this loop does the rest. It exists // for a session exactly while a message is queued there — accepted, not yet handed over — and -// every step re-reads the journal to decide, so there is no loop state to disagree with it. -// Each step is its own serialized task. That is what lets a Stop that arrives while a start holds -// the queue withdraw the queued messages before the handover that would have written them. +// every step re-reads the journal and the conversation's child record to decide, so there is no +// loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop +// that arrives while a start holds the queue withdraw the queued messages before the handover that +// would have written them. Stop and the conversation's close are the only other writers of a +// queued message: a child's exit only ends the child, and this loop reads why. import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + providerExitBeforeDeliveryRejection, + providerStartupFailureOutcome +} from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionEndedChild, + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' import { oldestQueuedSubmission, recordStructuredAgentSessionStartFailure @@ -22,6 +32,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter serialize: (sessionId: string, task: () => Promise) => Promise + /** A start step, tracked from enqueue so quit waits for the child it may produce. */ + trackStart: (start: Promise) => Promise /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ ensureProviderChild: (sessionId: string) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ @@ -33,6 +45,13 @@ export type StructuredAgentSessionDeliveryLoopDeps = { type Step = 'continue' | 'stop' +type Prepared = + | 'stop' + | { ok: false; refusal: AgentSessionWireRefusal } + | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } + +type StartFailure = { startKey: string | null; text: string } + export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() private disposed = false @@ -43,8 +62,7 @@ export class StructuredAgentSessionDeliveryLoop { return this.running.has(sessionId) } - /** Quit: no step after this one starts a child or hands a message over; what is still queued - * is left for the next open, which rejects it as never sent. */ + /** Quit: no step after this one starts a child or hands a message over. */ dispose(): void { this.disposed = true } @@ -62,19 +80,24 @@ export class StructuredAgentSessionDeliveryLoop { private async run(sessionId: string): Promise { try { for (;;) { - const ready = await this.deps.serialize(sessionId, () => this.prepare(sessionId)) - if (ready === 'stop') { + const prepared = await this.deps.trackStart( + this.deps.serialize(sessionId, () => this.prepare(sessionId)) + ) + if (prepared === 'stop') { return } - if (!ready.ok) { - const text = this.deps.startFailureText(sessionId, ready.refusal) - await this.deps.serialize(sessionId, () => this.fail(sessionId, text)) + if (!prepared.ok) { + const text = this.deps.startFailureText(sessionId, prepared.refusal) + await this.deps.serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) return } // A child published before it proved its start takes no input yet; waited for outside // the queue so a Stop can reach it meanwhile. - await this.deps.adapter.awaitStarted?.(sessionId) - if ((await this.deps.serialize(sessionId, () => this.handOver(sessionId))) === 'stop') { + const failure = await this.deps.adapter.awaitStarted?.(sessionId) + const handed = await this.deps.serialize(sessionId, () => + this.handOver(sessionId, prepared.awaited, failure || null) + ) + if (handed === 'stop') { return } } @@ -85,7 +108,7 @@ export class StructuredAgentSessionDeliveryLoop { message: error instanceof Error ? error.message : String(error) }) await this.deps - .serialize(sessionId, () => this.fail(sessionId, text)) + .serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) .catch((failure: unknown) => { // Rows left queued are rejected by the next open, or by the next loop an accept wakes. this.running.delete(sessionId) @@ -95,7 +118,7 @@ export class StructuredAgentSessionDeliveryLoop { } /** Settles what an earlier host process left queued, then makes the session ready. */ - private async prepare(sessionId: string): Promise { + private async prepare(sessionId: string): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) @@ -109,18 +132,47 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldestQueuedSubmission(session)) { return this.stop(sessionId) } - return this.deps.ensureProviderChild(sessionId) + const ready = await this.deps.ensureProviderChild(sessionId) + if (!ready.ok) { + return ready + } + const child = this.deps.sessions.get(sessionId)?.child + // The child this run waits on; handover checks it is still the one there. + return { + ok: true, + awaited: child ? { generation: child.generation, fence: child.fence } : null + } } - private async handOver(sessionId: string): Promise { + private async handOver( + sessionId: string, + awaited: StructuredAgentSessionProviderChildIdentity | null, + startFailure: string | null + ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) } - // Re-derived here, not carried from the start: the child may have gone since. + // Re-derived here, not carried from the start: the child may have ended, or another may have + // taken its place, since. const { child } = session - if (!child) { - return 'continue' + const awaitedChild = + child && awaited && child.generation === awaited.generation && child.fence === awaited.fence + ? child + : null + // The host's `starting` trails the adapter's `started` by one serialized step, so for the child + // waited on, the adapter's own answer decides whether its start landed. + if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { + // The child waited on is gone, replaced by another, or settled its start without proving it. + const ended = awaitedChild ? undefined : session.lastEndedChild + // A Stop is not a failure: the next step starts, or waits on, a child for what is queued. + if (ended?.cause === 'stop') { + return 'continue' + } + return this.fail(sessionId, { + startKey: awaited?.generation ?? null, + text: ended ? endedChildRejection(ended) : (startFailure ?? providerStartupFailureOutcome()) + }) } const next = oldestQueuedSubmission(session) if (!next) { @@ -130,7 +182,7 @@ export class StructuredAgentSessionDeliveryLoop { { sessionId, journal: session.journal, - fence: child.fence, + fence: awaitedChild.fence, adapter: this.deps.adapter, providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase }, @@ -139,12 +191,12 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } - private async fail(sessionId: string, text: string): Promise { + private async fail(sessionId: string, failure: StartFailure): Promise { const session = this.deps.sessions.get(sessionId) if (session) { await recordStructuredAgentSessionStartFailure( { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, - text + failure ) } return this.stop(sessionId) @@ -156,3 +208,11 @@ export class StructuredAgentSessionDeliveryLoop { return 'stop' } } + +/** Why a queued message the child never took is rejected, in the words the chat row uses. */ +function endedChildRejection(ended: StructuredAgentSessionEndedChild): string { + const reason = ended.reason ?? undefined + return ended.duringStartup + ? providerStartupFailureOutcome(reason) + : providerExitBeforeDeliveryRejection(reason) +} 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 8b25364c8819..41b83e77eae0 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 @@ -35,6 +35,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { deps: StructuredAgentSessionHostDeps sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise + trackStart: (start: Promise) => Promise ensureProviderChild: (sessionId: string) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void publishRestored: (sessionId: string) => void @@ -44,6 +45,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessions, adapter: deps.adapter, serialize: input.serialize, + trackStart: input.trackStart, ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 910350982571..ebc718443d5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -47,25 +47,38 @@ export type StructuredAgentSessionLifetimeContext = { } } +type ConversationCloseDeps = Pick & { + store: Pick +} + +/** A conversation's handle closes with nothing queued: what is still queued when the chat closes, + * or the app quits, will not be handed over. Best effort: the next open's delivery loop rejects a + * leftover itself. */ +export async function abandonQueuedStructuredAgentSessionMessages( + deps: ConversationCloseDeps, + sessionId: string, + journal: StructuredAgentSessionHostSession['journal'] +): Promise { + await journal + .rejectQueuedSubmissions( + structuredAgentSessionConversationFence(deps.store, sessionId), + DISPATCH_REJECTED_PROVIDER_CLOSED + ) + .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) +} + /** Dropping a session and dropping its status row are ONE operation: the store keeps the row until - * told, so a caller that only deletes strands a live-looking row no reader can ever decay. A - * handle closes with nothing queued: what is still queued now will not be handed over. */ + * told, so a caller that only deletes strands a live-looking row no reader can ever decay. */ export async function forgetStructuredAgentSession( context: Pick & { - deps: Pick & { - store: Pick - } + deps: ConversationCloseDeps }, sessionId: string ): Promise { const session = context.sessions.get(sessionId) - await session?.journal - .rejectQueuedSubmissions( - structuredAgentSessionConversationFence(context.deps.store, sessionId), - DISPATCH_REJECTED_PROVIDER_CLOSED - ) - // Best effort: the next open rejects a leftover itself. - .catch((error: unknown) => context.deps.onEventSinkError?.({ sessionId, error })) + if (session) { + await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) + } await session?.journal.close() context.sessions.delete(sessionId) context.forgetStatus(sessionId) @@ -147,7 +160,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, - queuedRejection: DISPATCH_REJECTED_PROVIDER_CLOSED, onError: (id, error) => { settlementError = error context.deps.onEventSinkError?.({ sessionId: id, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 6b1693c3dca2..2f1b0f3c287a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -9,6 +9,7 @@ import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { + abandonQueuedStructuredAgentSessionMessages, evictOwnedStructuredAgentSessions, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' @@ -93,6 +94,8 @@ export async function tearDownStructuredAgentSessionHost(input: { sessions: Map retainSessionIds?: ReadonlySet acknowledgeSessionRelease?: (sessionId: string) => void + /** Quit closes every conversation, so it settles what they still queue as a close does. */ + abandonQueued?: (sessionId: string, session: StructuredAgentSessionHostSession) => Promise }): Promise { const failures: unknown[] = [] for (const phase of input.phases) { @@ -107,7 +110,12 @@ export async function tearDownStructuredAgentSessionHost(input: { ([sessionId]) => !input.retainSessionIds?.has(sessionId) ) // `allSettled`, so one rejected close cannot skip the others. - const closed = await Promise.allSettled(entries.map(([, session]) => session.journal.close())) + const closed = await Promise.allSettled( + entries.map(async ([sessionId, session]) => { + await input.abandonQueued?.(sessionId, session) + await session.journal.close() + }) + ) closed.forEach((result, index) => { const sessionId = entries[index]?.[0] if (result.status === 'fulfilled') { @@ -160,6 +168,8 @@ export async function flushStructuredAgentSessionHost( sessions: context.sessions, retainSessionIds, acknowledgeSessionRelease: (sessionId) => - context.deps.adapter.acknowledgeSessionRelease?.(sessionId) + context.deps.adapter.acknowledgeSessionRelease?.(sessionId), + abandonQueued: (sessionId, session) => + abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) }) } 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 bfe626813bd7..5976ea26498c 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 @@ -108,6 +108,8 @@ export class StructuredAgentSessionHost { deps, sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), + // Quit drains a delivery start before it evicts, so the child it produces is stopped. + trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId) => this.holds.ensureProviderChild(sessionId), reset: (...args) => this.subscribers.reset(...args), publishRestored: this.clientDelivery.publishRestored diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 3f21a8076739..c99467db927b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -12,7 +12,11 @@ import type { AgentSessionStatusSummary, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_PROVIDER_CLOSED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -155,14 +159,47 @@ function submission(id: string): AgentJournalSubmission | undefined { return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) } -function statusRows(): { text: string; tone?: string }[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' - ? [{ text: item.body.text, ...(item.body.tone ? { tone: item.body.tone } : {}) }] - : [] - ) +function statusRows(): { itemId: string; text: string; tone?: string }[] { + return host.journalSnapshot(SESSION).items.flatMap((item) => + item.body.kind === 'status' + ? [ + { + itemId: item.itemId, + text: item.body.text, + ...(item.body.tone ? { tone: item.body.tone } : {}) + } + ] + : [] + ) +} + +/** The child the conversation has now, as its lifecycle events name it. */ +function currentChild() { + const child = conversation()?.child + if (!child?.generation) { + throw new Error('no child indexed') + } + return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } +} + +function exit(child: ReturnType, reason: string, startupUnproven?: true) { + return host.handleAdapterEvent({ + type: 'ended', + ...child, + reason, + cause: 'unexpected-exit', + ...(startupUnproven ? { startupUnproven } : {}) + }) +} + +function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { + return events.some( + (event) => + event.type === 'batch' && + event.batch.submissions.some( + (entry) => entry.clientMessageId === id && entry.dispatchState === 'rejected' + ) + ) } function conversation() { @@ -276,3 +313,163 @@ describe('a settlement retry for an earlier child inside the attach for the next expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) }) }) + +describe('a published child that dies while it proves its start', () => { + const EXIT = 'claude stream-json exited (code 1)' + const TEXT = providerStartupFailureOutcome(EXIT) + + it.each([['the loop sees the start fail first'], ['the exit is processed first']])( + 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', + async (order) => { + const settled = deferred() + adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + const first = await accept('first') + const events = subscribe() + const second = await accept('second') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalled()) + const child = currentChild() + + if (order === 'the exit is processed first') { + await exit(child, EXIT, true) + settled.resolve(TEXT) + } else { + settled.resolve(TEXT) + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + await exit(child, EXIT, true) + } + + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + expect(statusRows()).toEqual([ + { + itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, + text: TEXT, + tone: 'error' + } + ]) + expect(submission(first)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) + expect(submission(second)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) + expect(rejectedIn(events, second)).toBe(true) + expect(dispatch).not.toHaveBeenCalled() + expect(acquire).toHaveBeenCalledTimes(2) + } + ) +}) + +describe('a child that ends before its message is handed over', () => { + it('starts one child for the message, then rejects it and stops (R2)', async () => { + // The child the loop starts exits between its start step and its handover step. + adapterExtras = { + awaitStarted: vi.fn(async () => { + await exit(currentChild(), 'codex app-server crashed') + }) + } + await restartHost() + const id = await accept('hello') + const events = subscribe() + + await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + expect(submission(id)?.reason).toContain('codex app-server crashed') + expect(statusRows()).toEqual([ + { itemId: expect.any(String), text: submission(id)?.reason, tone: 'error' } + ]) + expect(rejectedIn(events, id)).toBe(true) + await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +describe('another child indexed while the loop waits on the one it started', () => { + it('hands nothing over until the child now there has proven its start (R2)', async () => { + const starts = new Map>>() + const startOf = (generation: string) => { + const start = starts.get(generation) ?? deferred() + starts.set(generation, start) + return start + } + adapterExtras = { + awaitStarted: vi.fn(() => startOf(currentChild().acquisitionGeneration).promise), + // The stop ends the child without settling the start the loop is waiting on. + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + const first = await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + const stopped = currentChild() + expect(await stop()).toMatchObject({ ok: true }) + const second = await accept('second') + // A view's hold starts its own child before the loop's handover step runs. + await host.hold(SESSION, 'surface-1') + const replacement = currentChild() + expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) + + startOf(stopped.acquisitionGeneration).resolve() + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(2)) + expect(dispatch).not.toHaveBeenCalled() + + await host.handleAdapterEvent({ + type: 'started', + ...replacement, + reportedOptions: { model: 'sonnet' }, + restoreSkippedOptions: [] + }) + startOf(replacement.acquisitionGeneration).resolve() + + await eventually(() => expect(submission(second)?.dispatchState).toBe('accepted')) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) + expect(submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + }) +}) + +describe('a quit with a message still queued', () => { + /** Read by the next launch, through the same open any reader takes. */ + async function afterRelaunch(id: string): Promise { + startHost() + await host.revealSession(SESSION) + return submission(id) + } + + it('settles a message no child ever had the way a chat close does (R2)', async () => { + // Quit has begun — its first step stops the delivery loops — when this message is accepted. + host['conversationDelivery'].loop.dispose() + const id = await accept('hello') + expect(conversation()?.child).toBeNull() + await host.flushAllStreamedEvents() + + expect(await afterRelaunch(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_PROVIDER_CLOSED + }) + }) + + it('waits for the start already in flight and stops the child it produced (R2)', async () => { + const starting = deferred() + const closeSession = vi.fn(async () => true) + adapterExtras = { closeSession } + await restartHost() + // The loop's start step is under way, but has not reached its attach yet. + const resolveRecovery = host['runtimeState'].resolveRecovery.bind(host['runtimeState']) + const recovering = vi.spyOn(host['runtimeState'], 'resolveRecovery') + recovering.mockImplementationOnce(async (sessionId) => { + await starting.promise + return resolveRecovery(sessionId) + }) + const id = await accept('hello') + await eventually(() => expect(recovering).toHaveBeenCalled()) + + const quit = host.flushAllStreamedEvents() + starting.resolve() + await quit + + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + expect(dispatch).not.toHaveBeenCalled() + expect(await afterRelaunch(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_PROVIDER_CLOSED + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 52a1d02f7c29..005949661012 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,35 +1,46 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { boundJournalStatusText } from '../agent-session-journal/journal-prompt-body-bounds' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' /** - * A start the delivery loop needed and did not get: one error row in the chat, so the reason - * outlives any error strip, and every queued message rejected with the same words. Keyed by the - * oldest message it failed, so a replayed failure adds no second row. Writes nothing when nothing - * is still queued: a start whose messages Stop withdrew did not fail anyone. + * The one row a start that failed leaves in the chat, whoever saw it fail: an error row, so the + * reason outlives any error strip. Keyed by the start — the child's generation, or the oldest + * message it was for when no child was ever published — so a second report of the same failure + * revises the row instead of adding one. + */ +export function structuredAgentSessionStartFailureRow( + startKey: string, + text: string +): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: { provider: 'orca', clientMessageId: `start-failure:${startKey}` }, + body: { kind: 'status', text: boundJournalStatusText(text), tone: 'error' } + } +} + +/** + * A start the delivery loop needed and did not get: the start's row, and every queued message + * rejected with the same words. Writes nothing when nothing is still queued: a start whose + * messages Stop withdrew did not fail anyone. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - text: string + failure: { startKey: string | null; text: string } ): Promise { const oldest = oldestQueuedSubmission(session) if (!oldest) { return } - const settlementId = `start-failure:${oldest.clientMessageId}` + const startKey = failure.startKey ?? oldest.clientMessageId await session.journal.appendLifecycleBatch({ - settlementId, + settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [ - { - kind: 'item', - identity: { provider: 'orca', clientMessageId: settlementId }, - body: { kind: 'status', text: boundJournalStatusText(text), tone: 'error' } - } - ] + mutations: [structuredAgentSessionStartFailureRow(startKey, failure.text)] }) - await session.journal.rejectQueuedSubmissions(session.fence, text) + await session.journal.rejectQueuedSubmissions(session.fence, failure.text) } export function oldestQueuedSubmission( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index c8b5b1f40c5a..ab2c8f0b9e80 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -25,8 +25,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectPendingSubmissions: vi.fn(async () => []), - rejectQueuedSubmissions: vi.fn(async () => []) + rejectPendingSubmissions: vi.fn(async () => []) } } } @@ -85,7 +84,9 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: providerStartupFailureOutcome(REASON) } + // The same row the delivery loop writes for a failed start: an error, keyed by it. + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, + body: { kind: 'status', text: providerStartupFailureOutcome(REASON), tone: 'error' } }) ] }) @@ -115,7 +116,9 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: providerStartupFailureOutcome(REASON) } + // The same row the delivery loop writes for a failed start: an error, keyed by it. + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, + body: { kind: 'status', text: providerStartupFailureOutcome(REASON), tone: 'error' } }) ] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 2c2863707c86..e5da2eece97c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -86,8 +86,7 @@ function feedFor( } } as unknown as ReadonlyMap>, // A partial record still has a lease: the feed reads the conversation's fence off it. - getRecord: () => - record && ({ lease: agentSessionRecordFixture().lease, ...record } as AgentSessionRecord), + getRecord: () => (record ? { ...agentSessionRecordFixture(), ...record } : null), now: () => (now += 1) }) const events: AgentSessionStatusEvent[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 5aae86f32910..42533ba2fe33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -32,7 +32,7 @@ function recoveryContext(input: { handoffStage?: AgentSessionRecord['lease']['handoffStage'] resumeCapable?: boolean }) { - const session = { + const session: Pick = { child: null, lastEndedChild: { generation: input.generation ?? GENERATION, @@ -41,7 +41,7 @@ function recoveryContext(input: { reason: null, duringStartup: false } - } as StructuredAgentSessionHostSession + } const record = { lease: { runtimeFence: 8, @@ -132,6 +132,7 @@ describe('provider-exit recovery tickets', () => { .fn() .mockRejectedValueOnce(new Error('journal unavailable')) .mockResolvedValue({ epoch: 'epoch-1', sequence: 2 }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit reads only the child record and these journal methods; the rest of the session is unreachable from it. const session = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { @@ -139,8 +140,7 @@ describe('provider-exit recovery tickets', () => { items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), appendLifecycleBatch, - markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectQueuedSubmissions: vi.fn(async () => []) + markPendingSubmissionsUnknown: vi.fn(async () => []) } } as unknown as StructuredAgentSessionHostSession @@ -201,13 +201,13 @@ describe('provider-exit recovery tickets', () => { lifecycleItem('turn-1', 1, { state: 'completed', startedAt: 10, completedAt: 20 }), lifecycleItem('turn-2', 2, { state: 'running', startedAt: 30 }) ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit reads only the child record and these journal methods; the rest of the session is unreachable from it. const session = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { snapshot: () => ({ items }), appendLifecycleBatch, - markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectQueuedSubmissions: vi.fn(async () => []) + markPendingSubmissionsUnknown: vi.fn(async () => []) } } as unknown as StructuredAgentSessionHostSession const store = { @@ -313,8 +313,7 @@ describe('provider-exit recovery tickets', () => { snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectPendingSubmissions: vi.fn(async () => []), - rejectQueuedSubmissions: vi.fn(async () => []) + rejectPendingSubmissions: vi.fn(async () => []) } } @@ -370,7 +369,6 @@ describe('provider-exit recovery tickets', () => { appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, rejectPendingSubmissions: vi.fn(async () => []), - rejectQueuedSubmissions: vi.fn(async () => []), submissions: () => [{ clientMessageId: 'client-1', dispatchState: 'pending' }] } } @@ -415,7 +413,6 @@ describe('provider-exit recovery tickets', () => { journal: { markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), - rejectQueuedSubmissions: vi.fn(async () => []), snapshot: () => ({ items: [lifecycleItem('turn-failing', 1, { state: 'running', startedAt: 1 })] }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 212963b85398..7f4a60005b87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -9,8 +9,6 @@ import type { StructuredAgentSessionSinkBarrier } from './structured-agent-sessi import { captureUnfinishedStructuredAgentSessionWork, MAX_UNEXPECTED_EXIT_REASON_CHARS, - providerExitBeforeDeliveryRejection, - providerStartupFailureRejection, settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted @@ -213,10 +211,9 @@ async function retryUnexpectedExitSettlement(input: { pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, unexpectedExitReason: input.event.reason, - exitedDuringStartup: input.exitedDuringStartup, - queuedRejection: input.exitedDuringStartup - ? providerStartupFailureRejection(input.event.reason) - : providerExitBeforeDeliveryRejection(input.event.reason), + ...(input.exitedDuringStartup + ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } + : {}), onError: input.context.onBarrierError }) } From 4993696a5330f54d01e5bd1e924b9a4023e9e46e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:01:21 -0700 Subject: [PATCH 042/101] refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. --- .../structured-agent-session-attach-failure.ts | 2 +- .../structured-agent-session-attach-flow.ts | 5 +++-- ...ctured-agent-session-attach-orchestration.ts | 14 ++++++++++---- .../structured-agent-session-eviction.test.ts | 4 ++++ .../structured-agent-session-eviction.ts | 16 +++++++++------- .../structured-agent-session-host-lifetime.ts | 5 +++-- .../structured-agent-session-host-types.ts | 17 +++++++++++------ ...-agent-session-provider-child-record.test.ts | 1 + ...ctured-agent-session-unexpected-exit.test.ts | 3 ++- .../structured-agent-session-unexpected-exit.ts | 4 +++- 10 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 89fb4654f66c..6af8eb369d52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -24,7 +24,7 @@ export async function settlePostAcquisitionAttachFailure( ? 'root-exit-observed' : 'exit-proven' } - input.onAcquisitionReleased?.(cause) + input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' }) try { await input.store.settleFailedPostAcquisitionAttachment({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 5390e3f0f1e7..bcc47bce67f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -69,8 +69,9 @@ export type AttachFlowInput = { beforeJournalOpen?: () => Promise | void /** The host's open conversation, which the attach adopts rather than opening a second one. */ openConversation?: (sessionId: string) => Promise - /** A failure after acquisition released the session's acquisition; `cause` is that failure. */ - onAcquisitionReleased?: (cause: unknown) => void + /** A failure after acquisition released the session's acquisition; `cause` is that failure and + * `rootGone` whether the release saw the provider root go. */ + onAcquisitionReleased?: (cause: unknown, verdict: { rootGone: boolean }) => void } export async function performAttach( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 1b6064726a3c..cc311aee77e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -24,7 +24,10 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionProviderChild, + StructuredAgentSessionStopVerdict +} from './structured-agent-session-host-types' import { endProviderChild, indexProviderChild, @@ -178,7 +181,8 @@ async function runAttach( return conversation.journal }, // The cleanup released the acquisition, which for a re-attach is the live child itself. - onAcquisitionReleased: (cause) => endReleasedChild(context, sessionId, cause), + onAcquisitionReleased: (cause, verdict) => + endReleasedChild(context, sessionId, cause, verdict), onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null @@ -247,7 +251,8 @@ type AttachCandidate = { function endReleasedChild( context: StructuredAgentSessionAttachContext, sessionId: string, - cause: unknown + cause: unknown, + verdict: StructuredAgentSessionStopVerdict ): void { const session = context.sessions.get(sessionId) const child = session?.child @@ -259,7 +264,8 @@ function endReleasedChild( fence: child.fence, cause: 'attach-failed', reason: cause instanceof Error ? cause.message : String(cause), - duringStartup: child.phase === 'starting' + duringStartup: child.phase === 'starting', + ...verdict }) ) { return diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 83f8ade9a4c8..8fc810de2a4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -174,9 +174,13 @@ describe('a child that will not stop', () => { ctx.adapter.closeSession = vi.fn(async () => { throw error }) + const stopped = vi.fn() + ctx.onProviderChildStopped = stopped await evictStructuredAgentSession(ctx) + // The host ends its child on the one reading of the verdict, not a second one of its own. + expect(stopped).toHaveBeenCalledWith({ rootGone: true }) expect(ctx.order).toEqual([ 'drained', 'settleWork', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index cc4dcc46be91..2c03143ff417 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -22,6 +22,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { @@ -37,8 +38,9 @@ export type StructuredAgentSessionEvictionContext = { /** Fires right before the stop, while the child's turn and background roster are still live. A * throw is logged, never allowed to abort the stop. */ beforeProviderChildStop?: () => void - /** Fires once the adapter has PROVEN the child gone, so host bookkeeping stops claiming one. */ - onProviderChildStopped?: () => void + /** Fires with the stop's verdict once `stopAgentSessionProviderRoot` read the root gone, so host + * bookkeeping stops claiming a child. */ + onProviderChildStopped?: (verdict: StructuredAgentSessionStopVerdict) => void /** Whether this host still owes the child's wind-down. Distinct from `hasProviderChild`, which a * proven exit retires mid-run: the two disagree for exactly the steps a retry has to repeat. */ owesProviderChildWindDown?: boolean @@ -83,13 +85,13 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe } // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession - if ( - stop && - !(await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId))) - ) { + const rootGone = stop + ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) + : true + if (!rootGone) { throw new Error('provider child exit was not proven') } - context.onProviderChildStopped?.() + context.onProviderChildStopped?.({ rootGone }) } }, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 33606f67d2d3..424aed3f95f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -134,14 +134,15 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), // Host state must not disagree with the adapter for the steps in between. - onProviderChildStopped: () => { + onProviderChildStopped: (verdict) => { if (stopping) { endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, cause: ending, reason: null, - duringStartup: stopping.phase === 'starting' + duringStartup: stopping.phase === 'starting', + ...verdict }) } context.restartWitness?.stopped(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 84fa67b93317..63333355687f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -40,14 +40,19 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider phase: StructuredAgentSessionProviderChildPhase } +/** What ending a child established about its provider root. A stop's comes only from + * `stopAgentSessionProviderRoot`; an observed exit's root is gone by definition. */ +export type StructuredAgentSessionStopVerdict = { rootGone: boolean } + /** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a * Stop from a failure. */ -export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChildIdentity & { - cause: 'exit' | 'attach-failed' | 'stop' | 'evict' - /** The provider's own diagnostic, when the ending carried one. */ - reason: string | null - duringStartup: boolean -} +export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChildIdentity & + StructuredAgentSessionStopVerdict & { + cause: 'exit' | 'attach-failed' | 'stop' | 'evict' + /** The provider's own diagnostic, when the ending carried one. */ + reason: string | null + duringStartup: boolean + } /** The conversation: its journal, params and readers outlive any child that serves it. */ export type StructuredAgentSessionHostSession = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index c99467db927b..be67339f1618 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -262,6 +262,7 @@ describe('Stop on a child still proving its start', () => { // The same conversation: no reopen, the holder kept, and the chat told it is idle again. expect(conversation()?.journal).toBe(journal) expect(conversation()?.child).toBeNull() + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'stop', rootGone: true }) expect(host.isHeld(SESSION)).toBe(true) expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 42533ba2fe33..fd04a946aa30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -39,7 +39,8 @@ function recoveryContext(input: { fence: 7, cause: 'exit', reason: null, - duringStartup: false + duringStartup: false, + rootGone: true } } const record = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 7f4a60005b87..f30521ab8077 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -78,7 +78,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< fence: child.fence, cause: 'exit', reason: unexpectedEvent.reason, - duringStartup: exitedDuringStartup + duringStartup: exitedDuringStartup, + // The adapter publishes an exit only once it saw the root go, first-hand or proven. + rootGone: true }) context.publishStatus?.(unexpectedEvent.sessionId) } From a2ad8f2b635ea53753aa9a60691096ae03c2cdd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:04:17 -0700 Subject: [PATCH 043/101] feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. --- src/main/runtime/rpc/methods/structured-agent-session.test.ts | 3 +++ src/shared/protocol-version.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 70da8409c37c..07e006ac6c7c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -150,6 +151,8 @@ describe('capability gating', () => { it('advertises the capability without bumping the protocol version', () => { expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + // A client tells a host that accepts first, and admits a writer-free Stop before a turn, by it. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY) // Separate from the structured capability on purpose: a host can serve the rest of the diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4ae97924dbcc..8a4e269d33da 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -374,6 +374,9 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + // The host side: it accepts a send before any agent has it, and a Stop with no writer before a + // turn starts, so a client may gate on either. + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, From 48def968ef02efb8279ccfc1e8b8ed3a18de61e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:17:16 -0700 Subject: [PATCH 044/101] refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. --- ...red-agent-session-acquisition-options.test.ts | 11 +++++++++++ ...ructured-agent-session-adopted-import.test.ts | 7 +++++-- .../structured-agent-session-adopted-import.ts | 15 +++------------ .../structured-agent-session-attach-flow.ts | 14 ++++---------- ...uctured-agent-session-attach-orchestration.ts | 4 ++-- ...d-agent-session-attach-reconciliation.test.ts | 3 +++ ...red-agent-session-attach-test-conversation.ts | 15 +++++++++++++++ .../structured-agent-session-attach.ts | 16 ++-------------- ...agent-session-processless-reservation.test.ts | 6 ++++++ 9 files changed, 51 insertions(+), 40 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index e0549e1816e9..d49637e3bb77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -15,6 +15,7 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' @@ -154,6 +155,7 @@ describe('structured session acquisition options', () => { store: initialStore, adapter: withHistory('created'), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -194,6 +196,7 @@ describe('structured session acquisition options', () => { store, adapter: withHistory('resumed'), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -228,6 +231,7 @@ describe('structured session acquisition options', () => { store, adapter: sessionAdapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -260,6 +264,7 @@ describe('structured session acquisition options', () => { store, adapter: sessionAdapter, journalRoot: root!, + openConversation: openTestAttachConversation(root!), authority: { spawnToken, claimKeyId: 'key-1', @@ -291,6 +296,7 @@ describe('structured session acquisition options', () => { store, adapter: adapter({ origin: 'created' }), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -330,6 +336,7 @@ describe('structured session acquisition options', () => { } }), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -369,6 +376,7 @@ describe('structured session acquisition options', () => { store, adapter: sessionAdapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -408,6 +416,7 @@ describe('structured session acquisition options', () => { store, adapter: failingAdapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -499,6 +508,7 @@ describe('structured session acquisition options', () => { store: target, adapter: failingAdapter, journalRoot: root!, + openConversation: openTestAttachConversation(root!, failingAdapter), authority: { spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', claimKeyId: 'key-1', @@ -585,6 +595,7 @@ describe('the tab id a create records', () => { store, adapter: adapter({ origin: 'created' }), journalRoot: root!, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index a8c19781d71a..4b8e1d87ba39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -11,6 +11,7 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' @@ -122,6 +123,7 @@ async function attach( store, adapter: sessionAdapter, journalRoot: root!, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -211,7 +213,7 @@ describe('adopting a provider conversation on create', () => { } ) - it('still releases acquisition and closes the provisional journal on an import write failure', async () => { + it('still releases acquisition on an import write failure, and leaves the conversation open', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-')) const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'valid source') @@ -223,7 +225,8 @@ describe('adopting a provider conversation on create', () => { await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) - expect(close).toHaveBeenCalledTimes(1) + // The journal is the conversation's, not the attach's: a failed import closes nothing. + expect(close).not.toHaveBeenCalled() }) it('leaves the conversation writable when the import fails after acquiring', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 63d00ef53981..7a85de6a473e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -1,7 +1,6 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' import { importLegacyTranscriptIntoJournal, @@ -60,18 +59,10 @@ export async function importAdoptedTranscript( params: AgentSessionAttachParams, attached: AttachedJournal, record: AgentSessionRecord, - prepared: JournalReplacementItem[] | null, - ownsJournal: boolean + prepared: JournalReplacementItem[] | null ): Promise { - try { - await applyAdoptedTranscript(params, attached, record, prepared) - } catch (error) { - // A journal the attach opened for itself has no other owner; the conversation's stays open. - if (ownsJournal) { - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) - } - throw error - } + // The journal is the conversation's, which outlives a failed import; nothing here closes it. + await applyAdoptedTranscript(params, attached, record, prepared) } async function applyAdoptedTranscript( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index bcc47bce67f4..b0108fd67736 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -67,8 +67,8 @@ export type AttachFlowInput = { onAcquiring?: () => Promise | void /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void - /** The host's open conversation, which the attach adopts rather than opening a second one. */ - openConversation?: (sessionId: string) => Promise + /** The conversation's own open journal, which the attach adopts: it never opens one itself. */ + openConversation: (record: AgentSessionRecord) => Promise /** A failure after acquisition released the session's acquisition; `cause` is that failure and * `rootGone` whether the release saw the provider root go. */ onAcquisitionReleased?: (cause: unknown, verdict: { rootGone: boolean }) => void @@ -212,16 +212,10 @@ export async function performAttach( params, journalRoot: input.journalRoot, adapter: input.adapter, - ...(input.openConversation ? { openConversation: input.openConversation } : {}), + openConversation: input.openConversation, providerHistoryWindow }) - await importAdoptedTranscript( - params, - attached, - record, - preparedTranscript.items, - input.openConversation === undefined - ) + await importAdoptedTranscript(params, attached, record, preparedTranscript.items) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) await store.recordOperationOutcome({ callerKey: input.callerKey, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index cc311aee77e8..743152b76b69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -173,8 +173,8 @@ async function runAttach( params, now: () => context.now(), recordPhase, - openConversation: async (id) => { - const conversation = await context.openConversation(id) + openConversation: async (record) => { + const conversation = await context.openConversation(record.sessionId) if (!conversation) { throw new Error('agent_session_identity_required') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 81fb5f927384..4e15b23d4d3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -13,6 +13,7 @@ import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { attachJournal, journalIdentityFor, @@ -85,6 +86,7 @@ async function attach(adapter: StructuredAgentSessionAdapter) { record: RECORD, params: PARAMS, journalRoot: root, + openConversation: openTestAttachConversation(root), adapter }) journals.track(attached.journal) @@ -170,6 +172,7 @@ describe('attachJournal restart reconciliation', () => { record: RECORD, params: PARAMS, journalRoot: root, + adapter, openConversation: async () => journal }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts new file mode 100644 index 000000000000..4b8037077aa8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -0,0 +1,15 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' + +/** For a test that attaches without a host: the conversation's journal, through the one open a + * host would take, so the attach under test adopts it the way it adopts the host's. */ +export function openTestAttachConversation( + journalRoot: string, + adapter: Pick = {} +): (record: AgentSessionRecord) => Promise { + return async (record) => + (await openStructuredAgentSessionConversationJournal({ journalRoot, adapter }, record)).session + .journal +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 6b75b159a332..e1ddea2c3259 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -39,7 +39,6 @@ import { agentSessionProviderHandleChainHead } from '../../../shared/agent-sessi import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' @@ -184,16 +183,14 @@ export async function attachJournal(input: { journalRoot: string adapter: StructuredAgentSessionAdapter /** The host's open conversation; omitted, the journal is opened for this caller alone. */ - openConversation?: (sessionId: string) => Promise + openConversation: (record: AgentSessionRecord) => Promise /** Provider history sampled before a new child is acquired. `null` means the * adapter had no usable history; omit to read lazily for direct callers. */ providerHistoryWindow?: ProviderHistoryWindow | null }): Promise { const identity = journalIdentityFor(input.record, input.params) const fence = input.record.lease.runtimeFence - const journal = input.openConversation - ? await input.openConversation(input.record.sessionId) - : await openJournalForCaller(input) + const journal = await input.openConversation(input.record) const settled = await reconcileAgainstProviderHistory({ adapter: input.adapter, identity, @@ -218,15 +215,6 @@ export async function attachJournal(input: { } } -async function openJournalForCaller(input: { - record: AgentSessionRecord - journalRoot: string - adapter: StructuredAgentSessionAdapter -}): Promise { - const opened = await openStructuredAgentSessionConversationJournal(input, input.record) - return opened.session.journal -} - /** Reading provider history is best effort: a provider that reports none, or a * read that fails, leaves every submission exactly as the crash boundary wrote * it. The journal writes the outcome implies are NOT caught here — a failed diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 0f115da5ebdc..83b1e138d09d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -12,6 +12,7 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' const NOW = 1_800_000_000_000 @@ -86,6 +87,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -133,6 +135,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -172,6 +175,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-drift', claimKeyId: 'key-1', @@ -226,6 +230,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -297,6 +302,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', From da65d574f8dfe1aec407cf276bd9a4ea75172253 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:23:12 -0700 Subject: [PATCH 045/101] fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. --- ...gent-session-provider-child-record.test.ts | 49 ++++++- ...tructured-agent-session-outbox-dispatch.ts | 12 ++ ...ctured-agent-session-outbox-fence.test.tsx | 138 ++++++++++++++++++ .../use-structured-agent-session-outbox.ts | 28 ++-- ...-session-accepted-send-capability.test.tsx | 51 +++++++ ...-agent-session-accepted-send-capability.ts | 87 +++++++++++ 6 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx create mode 100644 src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx create mode 100644 src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index be67339f1618..e13f17dff8f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -118,13 +118,12 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -async function accept(text: string): Promise { +function sendParams(text: string) { const body = hostTestMessage(text) - const clientOperationId = hostTestOperationId() - const sent = await host.send(CALLER, { + return { envelope: { sessionId: SESSION, - clientOperationId, + clientOperationId: hostTestOperationId(), expectedRuntimeFence: 1, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', @@ -133,9 +132,14 @@ async function accept(text: string): Promise { }) }, body - }) + } +} + +async function accept(text: string): Promise { + const params = sendParams(text) + const sent = await host.send(CALLER, params) expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - return clientOperationId + return params.envelope.clientOperationId } function stop() { @@ -474,3 +478,36 @@ describe('a quit with a message still queued', () => { }) }) }) + +describe('a send whose start failed, sent again with the same operation id', () => { + it('replays the recorded rejection and starts no second agent (R2)', async () => { + acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) + const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence + const params = sendParams('hello') + // A failed start is a rejected message, never a refused send. + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + const id = params.envelope.clientOperationId + await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + // The start moved the fence while the message was out. + expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) + const starts = acquire.mock.calls.length + + const replay = await host.send(CALLER, params) + + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } + }) + await settleLoop() + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +async function settleLoop(): Promise { + await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index fc2d5adf1508..e0378b6cfe34 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -56,6 +56,18 @@ export function readMountedStructuredAgentSessionOutbox( ) } +/** A send left dispatching when its owner changed goes out again, under the same id. */ +export function requeueInterruptedStructuredAgentSessionDispatches( + entries: StructuredAgentSessionOutboxEntry[], + fence: number | null +): StructuredAgentSessionOutboxEntry[] { + return entries.map((entry) => + entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) + ? { ...entry, state: 'queued' as const } + : entry + ) +} + export function dispatchStructuredAgentSessionOutboxEntry(args: { next: StructuredAgentSessionOutboxEntry persisted: readonly StructuredAgentSessionOutboxEntry[] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx new file mode 100644 index 000000000000..fd306a537307 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom + +// A moved fence is not a reason to send anything again on a host that records every send before it +// starts an agent. There, only a Retry or a new send goes out. An older host, which restarts the +// agent inside the send and refuses it unrecorded when that fails, keeps the resend on a new fence. + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +const LOCAL_TARGET = { kind: 'local' } as const + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function pendingResult(clientMessageId: string) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending' as const, + handoverRecorded: true, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + } + } +} + +function sentId(call: number): string { + const id: unknown = mocks.call.mock.calls[call]?.[2].envelope.clientOperationId + return String(id) +} + +function render() { + return renderHook( + ({ fence }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence, + submissions: [] + }), + { initialProps: { fence: 1 } } + ) +} + +/** Long enough for any effect a fence change schedules to have sent. */ +async function settle(): Promise { + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) +} + +describe('an outbox on a host that accepts a send before any agent has it', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + }) + + it('neither resends nor drops the answer of a send in flight when the fence moves', async () => { + const answer = deferred>() + mocks.call.mockReturnValueOnce(answer.promise) + const { result, rerender } = render() + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + // A start or restart on the host moves the fence while the send is out. + rerender({ fence: 2 }) + rerender({ fence: 3 }) + await settle() + expect(mocks.call).toHaveBeenCalledTimes(1) + + await act(async () => answer.resolve(pendingResult(sentId(0)))) + // The answer lands: the entry is the host's now, not re-queued behind a moved fence. + expect(result.current.outbox).toMatchObject([{ state: 'dispatching' }]) + await settle() + expect(mocks.call).toHaveBeenCalledTimes(1) + }) + + it('keeps a blocked head blocked across a fence change; only Retry sends it', async () => { + mocks.call.mockRejectedValueOnce(new Error('send failed')).mockResolvedValue({ ok: true }) + const { result, rerender } = render() + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + rerender({ fence: 2 }) + await settle() + expect(mocks.call).toHaveBeenCalledTimes(1) + expect(result.current.error).toBe('Error: send failed') + + act(() => result.current.retry(result.current.outbox[0]!.clientMessageId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + }) +}) + +describe('an outbox on an older host', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + setLocalRuntimeCapabilitiesForTests([]) + }) + + it('still resends a send in flight when the fence moves, as the new owner may take it', async () => { + mocks.call.mockReturnValueOnce(new Promise(() => {})).mockReturnValue(new Promise(() => {})) + const { result, rerender } = render() + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + rerender({ fence: 2 }) + + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(sentId(1)).toBe(sentId(0)) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index c8e2f87420c5..2c9563e57447 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -16,10 +16,11 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' import { dispatchStructuredAgentSessionOutboxEntry, - hasInFlightLaunchDispatch, - readMountedStructuredAgentSessionOutbox + readMountedStructuredAgentSessionOutbox, + requeueInterruptedStructuredAgentSessionDispatches } from './structured-agent-session-outbox-dispatch' import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-session-launch-prompt' +import { useStructuredAgentSessionOutboxOwnerChange } from '@/runtime/structured-agent-session-accepted-send-capability' export function structuredSessionOperationId(): string { return createStructuredAgentSessionOperationId(() => crypto.randomUUID()) @@ -28,8 +29,8 @@ export function structuredSessionOperationId(): string { const UNCONFIRMED_PROBE_BASE_DELAY_MS = 1_000 /** No attempt ceiling: a transport outage outlives any fixed budget, and giving up * restores the wedge this fixes. Growth caps the rate at one status query per 16s. - * A refusal that blocks the head still ends probing until a fence change or a manual - * Retry, because the entry leaves `unconfirmed` -- pre-existing, not closed here. */ + * A refusal that blocks the head still ends probing until a manual Retry (or, on an older + * host, a fence change), because the entry leaves `unconfirmed`. */ const UNCONFIRMED_PROBE_MAX_DELAY_MS = 16_000 export function useStructuredAgentSessionOutbox(args: { @@ -39,7 +40,8 @@ export function useStructuredAgentSessionOutbox(args: { submissions: readonly AgentJournalSubmission[] }) { const { fence, sessionId, submissions, target } = args - const targetKey = target.kind === 'local' ? 'local' : `environment:${target.environmentId}` + // What resends, unblocks and drops a send in flight besides a Retry or a new send; see the hook. + const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) const [outbox, setOutbox] = useState(() => readMountedStructuredAgentSessionOutbox(sessionId, fence, readOutbox) ) @@ -71,19 +73,15 @@ export function useStructuredAgentSessionOutbox(args: { blockedIdRef.current = null retryWithFreshClientMessageIdRef.current = null probeAttemptsRef.current = { id: null, attempts: 0 } - }, [fence, sessionId, targetKey]) + }, [owner.ownerChange, owner.targetKey, sessionId]) useEffect(() => { const sessionChanged = outboxSessionRef.current !== sessionId outboxSessionRef.current = sessionId const current = sessionChanged - ? readMountedStructuredAgentSessionOutbox(sessionId, fence, readOutbox) + ? readMountedStructuredAgentSessionOutbox(sessionId, owner.fenceRef.current, readOutbox) : outboxRef.current - const next = current.map((entry) => - entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) - ? { ...entry, state: 'queued' as const } - : entry - ) + const next = requeueInterruptedStructuredAgentSessionDispatches(current, owner.fenceRef.current) if ( sessionChanged || next.some((entry, index) => entry !== current[index]) || @@ -93,7 +91,7 @@ export function useStructuredAgentSessionOutbox(args: { setOutbox(next) writeOutbox(sessionId, next) } - }, [fence, sessionId, target]) + }, [owner.fenceRef, owner.ownerChange, sessionId, target]) useEffect(() => { const current = outboxRef.current @@ -246,7 +244,7 @@ export function useStructuredAgentSessionOutbox(args: { const probeSettled = probeId !== null && submissions.some((submission) => submission.clientMessageId === probeId) useEffect(() => { - if (probeId === null || probeSettled || fence === null) { + if (probeId === null || probeSettled || !owner.attached) { return } const attempts = probeAttemptsRef.current.id === probeId ? probeAttemptsRef.current.attempts : 0 @@ -263,7 +261,7 @@ export function useStructuredAgentSessionOutbox(args: { Math.min(UNCONFIRMED_PROBE_BASE_DELAY_MS * 2 ** attempts, UNCONFIRMED_PROBE_MAX_DELAY_MS) ) return () => clearTimeout(timer) - }, [fence, probeId, probeSettled, sessionId, targetKey]) + }, [owner.attached, owner.ownerChange, owner.targetKey, probeId, probeSettled, sessionId]) const send = useCallback( (text: string, attachments: readonly { path: string; previewUri: string }[] = []): boolean => { diff --git a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx new file mode 100644 index 000000000000..acdfdbb03233 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom + +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' + +const mocks = vi.hoisted(() => ({ supports: vi.fn() })) + +vi.mock('./runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supports +})) + +import { setLocalRuntimeCapabilitiesForTests } from './local-runtime-capabilities' +import { useStructuredAgentSessionHostAcceptsSend } from './structured-agent-session-accepted-send-capability' + +afterEach(() => { + setLocalRuntimeCapabilitiesForTests(null) + vi.clearAllMocks() +}) + +describe('whether a host accepts a send before any agent has it', () => { + it('reads the local host from its advertised capabilities', () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + const { result } = renderHook(() => useStructuredAgentSessionHostAcceptsSend({ kind: 'local' })) + expect(result.current).toBe(true) + }) + + it('treats a local host that does not advertise it as an older one', () => { + setLocalRuntimeCapabilitiesForTests([]) + const { result } = renderHook(() => useStructuredAgentSessionHostAcceptsSend({ kind: 'local' })) + expect(result.current).toBe(false) + }) + + it('asks a remote host, and reads a failed probe as an older host', async () => { + mocks.supports.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('offline')) + const accepts = renderHook(() => + useStructuredAgentSessionHostAcceptsSend({ kind: 'environment', environmentId: 'env-1' }) + ) + await waitFor(() => expect(accepts.result.current).toBe(true)) + expect(mocks.supports).toHaveBeenCalledWith( + 'env-1', + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) + + const offline = renderHook(() => + useStructuredAgentSessionHostAcceptsSend({ kind: 'environment', environmentId: 'env-2' }) + ) + await waitFor(() => expect(mocks.supports).toHaveBeenCalledTimes(2)) + expect(offline.result.current).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts new file mode 100644 index 000000000000..c6e130b8bd5f --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts @@ -0,0 +1,87 @@ +import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' +import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeClientTarget } from './runtime-client-target' +import { + ensureLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown +} from './local-runtime-capabilities' +import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' + +function targetKey(target: RuntimeClientTarget): string { + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} + +/** + * Whether the host answers a send at acceptance and never refuses one because its agent could not + * start. Such a host records every send before it starts anything, so nothing about a moved fence + * calls for a resend. False until the host has said so: an older host is handled as it always was. + */ +export function useStructuredAgentSessionHostAcceptsSend(target: RuntimeClientTarget): boolean { + const key = targetKey(target) + const [answer, setAnswer] = useState<{ key: string; accepts: boolean }>(() => ({ + key, + accepts: + target.kind === 'local' && + (readLocalRuntimeCapabilitiesOrUnknown()?.includes( + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) ?? + false) + })) + const environmentId = target.kind === 'environment' ? target.environmentId : null + useEffect(() => { + let cancelled = false + const probe = + environmentId === null + ? ensureLocalRuntimeCapabilities().then( + (capabilities) => + capabilities?.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) ?? false + ) + : runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) + void probe + .catch(() => false) + .then((accepts) => { + if (!cancelled) { + setAnswer((current) => + current.key === key && current.accepts === accepts ? current : { key, accepts } + ) + } + }) + return () => { + cancelled = true + } + }, [environmentId, key]) + return answer.key === key && answer.accepts +} + +/** + * When an outbox treats its owner as changed: resending a send in flight under the same id, + * dropping that send's answer, and unblocking a refused head. An older host restarts the agent + * inside the send and refuses it, unrecorded, when that fails, so a new fence is its only word that + * another try may land. A host that accepts first records every send before it starts anything, + * so a moved fence means nothing there, and only a Retry or a new send goes out. + */ +export function useStructuredAgentSessionOutboxOwnerChange( + target: RuntimeClientTarget, + fence: number | null +): { + ownerChange: number | null + attached: boolean + fenceRef: RefObject + targetKey: string +} { + const acceptsSend = useStructuredAgentSessionHostAcceptsSend(target) + // Read by effects that run on an owner change, not on every fence move. + const fenceRef = useRef(fence) + useLayoutEffect(() => { + fenceRef.current = fence + }, [fence]) + return { + ownerChange: acceptsSend ? null : fence, + attached: fence !== null, + fenceRef, + targetKey: targetKey(target) + } +} From 0184a3d814bf7ef25049195d55130d7c7734fc26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:42:51 -0700 Subject: [PATCH 046/101] refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. --- .../structured-agent-session-delivery-loop.ts | 10 +++- .../structured-agent-session-host-lifetime.ts | 18 ++++--- .../structured-agent-session-host-types.ts | 13 ++++- .../structured-agent-session-host.ts | 4 +- ...gent-session-provider-child-record.test.ts | 51 ++++++++++++++++++- 5 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 3ea91214ad03..936fc639015c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -165,8 +165,9 @@ export class StructuredAgentSessionDeliveryLoop { if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { // The child waited on is gone, replaced by another, or settled its start without proving it. const ended = awaitedChild ? undefined : session.lastEndedChild - // A Stop is not a failure: the next step starts, or waits on, a child for what is queued. - if (ended?.cause === 'stop') { + // A user's Stop is not a failure: the next step starts, or waits on, a child for what is + // queued. A host stop is: its cause is why the start did not land. + if (ended?.cause === 'user-stop') { return 'continue' } return this.fail(sessionId, { @@ -209,8 +210,13 @@ export class StructuredAgentSessionDeliveryLoop { } } +const HOST_STOPPED_BEFORE_DELIVERY = 'Orca stopped the agent before this message was sent.' + /** Why a queued message the child never took is rejected, in the words the chat row uses. */ function endedChildRejection(ended: StructuredAgentSessionEndedChild): string { + if (ended.cause === 'host-stop') { + return ended.reason ?? HOST_STOPPED_BEFORE_DELIVERY + } const reason = ended.reason ?? undefined return ended.duringStartup ? providerStartupFailureOutcome(reason) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 424aed3f95f7..6b2b869548c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -17,6 +17,7 @@ import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-s import { StructuredAgentSessionHolds } from './structured-agent-session-holds' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { + StructuredAgentSessionChildEndCause, StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -104,13 +105,16 @@ function owedProviderChildWindDown( /** * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next - * stop is a real retry. `ending` is how the child's end is told: a Stop, or an eviction whose close - * forgets the conversation next. + * stop is a real retry. `ending` is how the child's end is told: a user's Stop, the host stopping it + * for a cause (with its text), or an eviction whose close forgets the conversation next. */ export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - ending: 'stop' | 'evict' = 'stop' + ending: { + cause: Extract + reason?: string + } = { cause: 'user-stop' } ): Promise { const session = context.sessions.get(sessionId) if (!session) { @@ -139,8 +143,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause: ending, - reason: null, + cause: ending.cause, + reason: ending.reason ?? null, duringStartup: stopping.phase === 'starting', ...verdict }) @@ -181,7 +185,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( }) } session.owesProviderChildWindDown = undefined - if (ending === 'evict') { + if (ending.cause === 'evict') { context.forgetStatus(sessionId) return } @@ -204,7 +208,7 @@ export async function evictHeldStructuredAgentSession( if (!context.sessions.has(sessionId)) { return } - await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, 'evict') + await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) await forgetStructuredAgentSession(context, sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 63333355687f..5a7161949c8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -44,12 +44,21 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider * `stopAgentSessionProviderRoot`; an observed exit's root is gone by definition. */ export type StructuredAgentSessionStopVerdict = { rootGone: boolean } +export type StructuredAgentSessionChildEndCause = + | 'user-stop' + | 'host-stop' + | 'exit' + | 'attach-failed' + | 'evict' + /** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a * Stop from a failure. */ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChildIdentity & StructuredAgentSessionStopVerdict & { - cause: 'exit' | 'attach-failed' | 'stop' | 'evict' - /** The provider's own diagnostic, when the ending carried one. */ + /** `user-stop` is a Stop the user asked for; `host-stop` is the host stopping the child for a + * cause of its own, which fails the start the delivery loop was waiting on. */ + cause: StructuredAgentSessionChildEndCause + /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ reason: string | null duringStartup: boolean } 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 d690512ae32a..c35a0ed1cbb7 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 @@ -278,7 +278,9 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId) => - stopStructuredAgentSessionAgentUnderSerialize(this.lifetimeContext(), sessionId), + stopStructuredAgentSessionAgentUnderSerialize(this.lifetimeContext(), sessionId, { + cause: 'user-stop' + }), now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index e13f17dff8f0..51f74871dc28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -20,6 +20,7 @@ import { providerStartupFailureOutcome } from './structured-agent-session-dead-g import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -266,7 +267,7 @@ describe('Stop on a child still proving its start', () => { // The same conversation: no reopen, the holder kept, and the chat told it is idle again. expect(conversation()?.journal).toBe(journal) expect(conversation()?.child).toBeNull() - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'stop', rootGone: true }) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) expect(host.isHeld(SESSION)).toBe(true) expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') @@ -511,3 +512,51 @@ describe('a send whose start failed, sent again with the same operation id', () async function settleLoop(): Promise { await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) } + +describe('how a stopped child ends the start its loop was waiting on', () => { + /** A child the loop waits on, whose start the stop below does not settle, so a message sent + * after the stop is queued when the loop next looks. */ + async function stoppedWhileStarting(stop: () => Promise) { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await stop() + const second = await accept('second') + adapterExtras.awaitStarted = undefined + start.resolve() + return second + } + + it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { + const second = await stoppedWhileStarting(async () => { + expect(await stop()).toMatchObject({ ok: true }) + }) + + await eventually(() => expect(submission(second)?.dispatchState).toBe('accepted')) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) + expect(statusRows()).toEqual([]) + }) + + it('fails the start after a host stop, with the stop as the reason (R2)', async () => { + const reason = 'Claude never finished starting, so Orca stopped it.' + const second = await stoppedWhileStarting(() => + host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'host-stop', + reason + }) + ) + ) + + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + expect(submission(second)?.reason).toBe(reason) + expect(statusRows()).toEqual([{ itemId: expect.any(String), text: reason, tone: 'error' }]) + expect(dispatch).not.toHaveBeenCalled() + }) +}) From 12577d84864be8f4baa98645f0086add50730ae3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:16:22 -0700 Subject: [PATCH 047/101] fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. --- ...tured-agent-session-restart-resume.test.ts | 61 +++++++++++++++++++ ...tured-agent-session-working-at-teardown.ts | 14 +++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 09a172f0b1d5..5cfd8012fe1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -324,6 +324,67 @@ describe('deriving what was working at teardown', () => { expect(recorded?.work).toEqual({ kind: 'submission', id: 'msg-1' }) }) + // Accepted while the agent was starting and never handed over: the chat shows working, but no + // agent had the message, and quit rejects it as never sent. + it('marks nothing for a session whose only work is a message still queued', () => { + const queued = { ...submission('msg-1', 'pending'), handoverRecorded: true } + expect( + markersAtTeardown({ + sessions: new Map([ + [ + SESSION, + { + journal: journal([turnItem('turn-0', 'completed')], false, [queued]), + child: { fence: 1 } + } + ] + ]), + getRecord: () => claudeRecord(null), + backgroundTasks: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + ).toEqual([]) + }) + + it('marks a message handed over and not yet answered, and a turn a queued one waits behind', () => { + const handedOver = { + ...submission('msg-1', 'pending'), + handoverRecorded: true, + handedOverAt: NOW + } + const queued = { ...submission('msg-2', 'pending'), handoverRecorded: true } + const markers = markersAtTeardown({ + sessions: new Map([ + [ + SESSION, + { + journal: journal([turnItem('turn-0', 'completed')], false, [handedOver]), + child: { fence: 1 } + } + ], + [ + 'session-running', + { + journal: journal([turnItem('turn-1', 'running')], false, [queued]), + child: { fence: 1 } + } + ] + ]), + getRecord: () => claudeRecord(null), + backgroundTasks: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + + expect(markers.map((entry) => entry.work)).toEqual([ + { kind: 'submission', id: 'msg-1' }, + { kind: 'turn', id: 'turn-1' } + ]) + }) + // Once a turn exists it is the better identity: it is what eviction rewrites, so it is what the // journal can be asked about at launch. it('prefers the running turn over the send that opened it', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 820f4c3c95da..e68df2c09a63 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -55,8 +55,6 @@ function pendingSubmissionInFlight( if ( submission && submission.recovered !== true && - // A queued message reached no agent, so there is no work of its to resume. - !isQueuedAgentJournalSubmission(submission) && (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') ) { return submission @@ -160,12 +158,20 @@ export function structuredAgentSessionWorkingAtStop(input: { return null } const snapshot = session.journal.snapshot() + // A queued message reached no agent, so it is no work to resume: quit rejects it as never sent. + const handedOver = snapshot.submissions.filter( + (submission) => !isQueuedAgentJournalSubmission(submission) + ) const roster = input.backgroundTasks(sessionId) - const status = structuredAgentSessionShownStatus(snapshot, roster, session.child.fence) + const status = structuredAgentSessionShownStatus( + { items: snapshot.items, submissions: handedOver }, + roster, + session.child.fence + ) if (status.state === 'done') { return null } - const work = structuredAgentSessionResumeWork(snapshot.items, snapshot.submissions) + const work = structuredAgentSessionResumeWork(snapshot.items, handedOver) const head = agentSessionProviderHandleChainHead( input.getRecord(sessionId)?.providerHandleChain ?? [] ) From e751c7e80ed3bcf68c11c46d92ab7f046e51a1fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:25:19 -0700 Subject: [PATCH 048/101] fix(native-chat): the conversation outlives its agent Opening a chat no longer starts its agent. A conversation is reached through one host accessor that opens its journal at rest, and a send is what starts the agent, through the delivery loop. One idle sweep, every five minutes, stops an agent that has been quiet for thirty minutes and owes no work, then drops an open journal handle that is only a cache. Its record, tab, status row and readers stay. - hold and release are no-ops; hold still builds the host for shipped mobile builds. - The holders, the holds, the release clock and the exit respawn are deleted. - Options, the model list, the goal and the context meter answer at rest; a model pick at rest is recorded as intent for the next start. - Compact, rewind, clear and goal changes start the agent first. A send does too when a rewind is still in doubt after the conversation opens. - Orchestration routes mail and group addresses on ownership (the record plus the chat tab), not on whether the process runs. An open dispatch keeps its worker running. - The restart continuation is a send; Resume all holds each slot until the message is handed over or rejected. - A read error never replaces a loaded transcript, and shows the host's own words. --- mobile/src/session/MobileNativeChatView.tsx | 2 +- .../mobile-native-chat-render-data.test.ts | 3 + .../session/mobile-native-chat-render-data.ts | 6 +- ...ctured-agent-session-hold-refused.test.tsx | 103 ++++++ .../use-mobile-structured-agent-state.ts | 65 ++-- ...claude-context-usage-unloaded-turn.test.ts | 10 +- src/main/claude/claude-structured-options.ts | 7 + .../claude-structured-session-adapter.ts | 3 +- .../codex/codex-structured-session-adapter.ts | 3 +- ...-agent-session-accept-then-deliver.test.ts | 123 ++++--- ...structured-agent-session-adapter-router.ts | 17 +- .../structured-agent-session-adapter.ts | 11 +- ...> structured-agent-session-agent-start.ts} | 73 ++-- ...structured-agent-session-attach-context.ts | 4 +- ...ured-agent-session-attach-orchestration.ts | 14 +- ...d-agent-session-background-task-channel.ts | 14 +- ...red-agent-session-claude-root-exit.test.ts | 15 +- ...tructured-agent-session-client-delivery.ts | 12 +- ...ructured-agent-session-close-retry.test.ts | 2 +- ...d-agent-session-conversation-close.test.ts | 301 +++++++++++++++ ...red-agent-session-conversation-lifetime.ts | 132 +++++++ ...ctured-agent-session-conversations.test.ts | 21 +- .../structured-agent-session-conversations.ts | 22 ++ .../structured-agent-session-delivery-loop.ts | 2 +- ...structured-agent-session-event-recovery.ts | 38 +- ...ctured-agent-session-forget-status.test.ts | 2 +- ...tured-agent-session-grouped-prompt.test.ts | 2 +- ...red-agent-session-hold-resume-race.test.ts | 260 ------------- .../structured-agent-session-holders.ts | 71 ---- .../structured-agent-session-holds.test.ts | 346 ------------------ .../structured-agent-session-holds.ts | 179 --------- .../structured-agent-session-host-delivery.ts | 2 +- .../structured-agent-session-host-lifetime.ts | 141 +++---- ...structured-agent-session-host-mutations.ts | 113 +++--- ...ctured-agent-session-host-teardown.test.ts | 6 +- .../structured-agent-session-host-teardown.ts | 6 +- ...uctured-agent-session-host-test-harness.ts | 2 +- .../structured-agent-session-host-types.ts | 8 +- .../structured-agent-session-host.test.ts | 40 +- .../structured-agent-session-host.ts | 124 +++---- ...tructured-agent-session-idle-sweep.test.ts | 255 +++++++++++++ .../structured-agent-session-idle-sweep.ts | 146 ++++++++ ...ured-agent-session-journal-handles.test.ts | 35 +- ...ured-agent-session-late-settlement.test.ts | 36 +- ...t-session-launch-send-after-create.test.ts | 9 +- ...gent-session-legacy-handoff-record.test.ts | 28 +- ...ed-agent-session-option-settlement.test.ts | 4 +- .../structured-agent-session-options-read.ts | 121 ++++++ ...gent-session-provider-child-record.test.ts | 78 ++-- ...red-agent-session-provider-restore.test.ts | 2 +- ...red-agent-session-provider-started.test.ts | 10 +- ...ructured-agent-session-provider-started.ts | 4 - ...uctured-agent-session-readable-restorer.ts | 29 +- ...tured-agent-session-recovery-exits.test.ts | 22 +- ...ctured-agent-session-refusal-retry.test.ts | 2 +- .../structured-agent-session-release-clock.ts | 84 ----- .../structured-agent-session-rest-test-rig.ts | 233 ++++++++++++ ...ructured-agent-session-restart-cap.test.ts | 85 +++++ ...d-agent-session-restart-child-work.test.ts | 17 +- ...-session-restart-continuation-wait.test.ts | 53 ++- ...agent-session-restart-continuation.test.ts | 58 ++- ...ured-agent-session-restart-continuation.ts | 174 +++++---- ...ent-session-restart-failure-filing.test.ts | 69 ++-- ...ssion-restart-interruption-test-harness.ts | 30 +- ...ssion-restart-offer-after-reattach.test.ts | 33 +- ...gent-session-restart-offer-endings.test.ts | 24 +- ...ed-agent-session-restart-ownership.test.ts | 150 +++----- ...ured-agent-session-restart-restore.test.ts | 67 ++-- ...tructured-agent-session-restart-restore.ts | 21 +- ...tured-agent-session-restart-resume-host.ts | 101 +++-- ...red-agent-session-restart-resume-runner.ts | 13 +- ...red-agent-session-restart-resume-wiring.ts | 11 +- ...red-agent-session-restart-teardown.test.ts | 2 +- .../structured-agent-session-reveal.test.ts | 201 ++-------- .../structured-agent-session-reveal.ts | 33 +- ...tured-agent-session-rewind-at-rest.test.ts | 225 ++++++++++++ .../structured-agent-session-rewind.test.ts | 102 +++--- .../structured-agent-session-rewind.ts | 3 + ...red-agent-session-send-preparation.test.ts | 236 ++---------- ...ructured-agent-session-send-preparation.ts | 35 ++ ...session-send-restarts-failed-start.test.ts | 38 +- .../structured-agent-session-send.test.ts | 8 +- ...agent-session-settled-attach-retry.test.ts | 11 +- ...red-agent-session-starting-release.test.ts | 73 ++-- ...agent-session-startup-failure-exit.test.ts | 15 +- ...red-agent-session-surface-lifetime.test.ts | 215 +++++------ ...ured-agent-session-unexpected-exit.test.ts | 82 +---- ...tructured-agent-session-unexpected-exit.ts | 61 +-- ...t-session-wedged-profile-migration.test.ts | 18 +- .../structured-conversation-command.test.ts | 4 +- .../structured-conversation-command.ts | 3 + .../runtime/agent-session-record-options.ts | 5 +- ...e-structured-resumed-start-failure.test.ts | 16 +- ...e-structured-send-held-for-startup.test.ts | 34 +- ...-send-restart-dies-before-dispatch.test.ts | 44 +-- ...ude-structured-session-integration.test.ts | 37 +- ...startup-unanswered-control-request.test.ts | 12 +- ...e-adopt-terminal-orphans-from-inventory.ts | 15 +- .../runtime/orca-runtime-get-worktree-ps.ts | 7 +- ...e-prune-mobile-session-tab-group-layout.ts | 5 +- .../orca-runtime-resolve-terminal-pane.ts | 2 +- ...l-handles-and-agent-status-part-02.spec.ts | 2 +- .../worker-terminal-transfer.ts | 40 +- ...tructured-mailbox-pointer-delivery.test.ts | 4 +- .../structured-mailbox-pointer-delivery.ts | 6 +- .../structured-mailbox-pointer-host.test.ts | 10 +- .../structured-mailbox-pointer-host.ts | 7 +- ...structured-worker-group-addressing.test.ts | 74 +++- .../structured-worker-group-addressing.ts | 37 +- .../structured-worker-journal-page.ts | 9 +- .../orchestration/worker-output-archive.ts | 2 +- .../methods/agent-launch-structured-prompt.ts | 6 +- ...ration-structured-worker-lifecycle.test.ts | 115 +++--- ...chestration-structured-worker-lifecycle.ts | 28 +- ...stration-structured-worker-session.test.ts | 47 ++- ...orchestration-structured-worker-session.ts | 41 +-- .../orchestration-worker-mode-opacity.test.ts | 10 +- .../messaging/send-group.test.ts | 2 +- .../orchestration/messaging/send-group.ts | 21 +- .../orchestration/worker/worker-control.ts | 2 +- .../worker/worker-observation.ts | 13 +- ...structured-agent-session-admission.test.ts | 14 +- .../structured-agent-session-at-rest.test.ts | 315 ++++++++++++++++ ...session-background-task-capability.test.ts | 3 +- ...ured-agent-session-conversation-outline.ts | 6 +- ...ession-gate-classification.test-fixture.ts | 7 +- .../methods/structured-agent-session-gate.ts | 15 +- .../structured-agent-session-hold.test.ts | 255 +++---------- .../methods/structured-agent-session-hold.ts | 57 +-- .../structured-agent-session-options-read.ts | 12 +- ...structured-agent-session-restart-resume.ts | 17 +- ...ructured-agent-session-rpc.test-fixture.ts | 3 +- ...agent-session-turn-item-capability.test.ts | 3 +- .../methods/structured-agent-session.test.ts | 4 +- .../rpc/methods/structured-agent-session.ts | 36 +- .../structured-worker-read-cursor.test.ts | 32 +- .../runtime-rpc-mobile-method-allowlist.ts | 2 + .../runtime/structured-agent-session-close.ts | 11 +- ...ructured-agent-session-integration.test.ts | 16 +- ...uctured-agent-session-runtime-exit.test.ts | 50 ++- ...t-session-runtime-provider-started.test.ts | 26 +- .../structured-agent-session-runtime.ts | 3 + .../structured-session-worktree-teardown.ts | 7 +- .../runtime/structured-worker-at-rest.test.ts | 222 +++++++++++ .../runtime/structured-worker-authority.ts | 50 ++- .../structured-worker-identity.test.ts | 18 +- .../runtime/structured-worker-identity.ts | 99 +++-- .../structured-worker-mail-routing.test.ts | 24 +- .../structured-worker-terminal-read.test.ts | 44 +-- .../structured-worker-terminal-read.ts | 6 +- .../native-chat/NativeChatEmptyState.test.tsx | 30 ++ .../native-chat/NativeChatEmptyState.tsx | 24 +- .../NativeChatStructuredSession.tsx | 2 +- .../native-chat-view-state.test.ts | 11 +- .../native-chat/native-chat-view-state.ts | 13 +- ...tured-agent-session-read-transport.test.ts | 31 +- ...structured-agent-session-read-transport.ts | 9 +- .../use-structured-agent-session-hold.ts | 3 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/native-chat-empty-state.ts | 5 + ...ss-version-agent-session-wire.unit.test.ts | 8 +- ...ructured-agent-session-surface-manifest.ts | 12 +- 168 files changed, 4481 insertions(+), 3370 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx rename src/main/native-chat/agent-session-wire/{structured-agent-session-hold-resume.ts => structured-agent-session-agent-start.ts} (66%) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts create mode 100644 src/main/runtime/structured-worker-at-rest.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 6a51601d151a..0043ba3ae1a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -287,7 +287,7 @@ export function MobileNativeChatView({ [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] ) - const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error, structuredActivityUi) const showLoading = status === 'loading' && messages.length === 0 const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 3c0281e24b88..6dd77f8a1b92 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -50,6 +50,9 @@ describe('mobileNativeChatEmptyState', () => { expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( 'The transcript could not be read. Toggle back to the terminal to keep working.' ) + expect(mobileNativeChatEmptyState('error', 'claude', undefined, true)?.subtitle).toBe( + 'The transcript could not be read. Orca keeps trying to load it.' + ) }) it('returns null for states that show no empty copy', () => { diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index da93efadbb87..43e31c5be19b 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -19,7 +19,9 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' export function mobileNativeChatEmptyState( status: MobileNativeChatStatus, agent: string | null, - error?: string + error?: string, + /** The structured lane, whose read retries on its own. */ + retrying = false ): NativeChatEmptyStateCopy | null { const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' switch (status) { @@ -31,7 +33,7 @@ export function mobileNativeChatEmptyState( case 'ready': return formatNativeChatEmptyStateCopy('empty', agentLabel) case 'error': { - const copy = formatNativeChatEmptyStateCopy('error', agentLabel) + const copy = formatNativeChatEmptyStateCopy(retrying ? 'retryingError' : 'error', agentLabel) return error ? { ...copy, subtitle: error } : copy } default: diff --git a/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx new file mode 100644 index 000000000000..cba5ec91ba84 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx @@ -0,0 +1,103 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a literal snapshot frame; the hook reads its type, page and fence. +const SNAPSHOT: AgentSessionSubscribeEvent = { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [ + { + itemId: 'msg-1', + revision: 1, + sequence: 1, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'still here' }] } + } + ], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 2 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 }, + hasOlder: false, + hasNewer: false + } +} as AgentSessionSubscribeEvent + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +it('subscribes when an older host refuses the hold, and shows the transcript (P2-24)', async () => { + const sendRequest = vi.fn(async (method: string) => + method === 'agentSession.hold' + ? { + ok: false, + error: { code: 'agent_session_owner_restart_failed', message: 'Codex could not start.' }, + _meta: { runtimeId: 'runtime-1' } + } + : { ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } } + ) + const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + onData(SNAPSHOT) + return () => undefined + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an RPC client stub with the two members the hook calls. + const client = { sendRequest, subscribe } as unknown as RpcClient + function Harness(): null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's options, of which this test supplies the ones it reads. + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + onSendError: () => undefined + } as never) + return null + } + + act(() => { + renderer = create(createElement(Harness)) + }) + + await vi.waitFor(() => + expect(subscribe).toHaveBeenCalledWith( + 'agentSession.subscribe', + { sessionId: 'session-1' }, + expect.any(Function) + ) + ) + await vi.waitFor(() => expect(hook?.session.messages.length).toBeGreaterThan(0)) + expect(hook?.session.status).not.toBe('error') +}) diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 494260049689..d2b6d1a50629 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -19,6 +19,33 @@ const MAX_RETAINED_SESSION_STATES = 32 /** Bounded so a busy stream cannot turn one Load-earlier tap into an endless read chain. */ const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 +/** + * Opens the transcript stream once the hold settles, either way: a refused hold is an older host + * saying it could not start the agent — which the next send does — never a reason to hide the + * transcript. Returns what ends the stream, opened or not yet. Outside the effect so its cleanup + * rule can see the stream is owned. + */ +function openTranscriptAfterHold( + client: RpcClient, + sessionId: string, + held: Promise, + onFrame: (raw: unknown) => void +): () => void { + let ended = false + let close = (): void => {} + void held + .catch(() => undefined) + .then(() => { + if (!ended) { + close = client.subscribe('agentSession.subscribe', { sessionId }, onFrame) + } + }) + return () => { + ended = true + close() + } +} + function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent { if (typeof value !== 'object' || value === null) { return false @@ -101,39 +128,21 @@ export function useMobileStructuredAgentState(args: { } apply({ type: 'loading' }) const holderId = structuredAgentSessionHolderId('mobile-chat') - let cancelled = false - let unsubscribe = (): void => {} const held = callAgentSession(client, 'agentSession.hold', { sessionId, holderId }) - void held - .then(() => { - if (cancelled) { - return - } - unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => { - if ( - typeof raw === 'object' && - raw !== null && - (raw as { type?: unknown }).type === 'error' - ) { - apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') }) - return - } - if (isSubscribeEvent(raw)) { - apply({ type: 'event', event: raw }) - } - }) - }) - .catch((error: unknown) => { - if (!cancelled) { - apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) - } - }) + const endStream = openTranscriptAfterHold(client, sessionId, held, (raw) => { + if (typeof raw === 'object' && raw !== null && 'type' in raw && raw.type === 'error') { + apply({ type: 'error', message: 'message' in raw ? String(raw.message ?? '') : '' }) + return + } + if (isSubscribeEvent(raw)) { + apply({ type: 'event', event: raw }) + } + }) return () => { - cancelled = true - unsubscribe() + endStream() void held .then(() => callAgentSession( diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 83d9248f6489..3e433d0f126c 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -18,10 +18,8 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { - readStructuredAgentSessionOptions, - type StructuredAgentSessionMutationContext -} from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' import { assistantFrame, initFrame, @@ -85,6 +83,7 @@ function readOptions( journal: AgentSessionJournal, adapter: Partial ) { + const running = { journal, fence: 1, hasProviderChild: true, params: { provider: 'claude' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the options read touches only these members. const context = { deps: { @@ -95,7 +94,8 @@ function readOptions( store: { getRecord: () => undefined } }, serialize: (_sessionId: string, task: () => Promise) => task(), - requireSession: () => ({ journal, fence: 1 }) + openConversation: async () => running, + conversation: async () => running } as unknown as StructuredAgentSessionMutationContext return readStructuredAgentSessionOptions(context, SESSION) } diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 0090cd2f039f..5a8d8466822a 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -41,6 +41,13 @@ export function restoredClaudeStructuredSessionOptions( ) } +/** The keys `setClaudeStructuredOption` writes; a pick made at rest is checked against these. */ +const CLAUDE_STRUCTURED_OPTION_KEYS = new Set(['model', 'permissionMode', 'effort', 'fastMode']) + +export function isClaudeStructuredOptionKey(key: string): boolean { + return CLAUDE_STRUCTURED_OPTION_KEYS.has(key) +} + /** A client's write; the startup restore writes through `setClaudeStructuredOption` directly. */ export function setClaudeStructuredSessionOption( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6e90d1547bdf..5d65ff125c5e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -230,7 +230,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - recordsContextUsage = (sessionId: string): boolean => this.sessions.has(sessionId) + // Provider-level: a session at rest still reports the usage its journal recorded. + recordsContextUsage = (): boolean => true readOptionRestoreFailures = (sessionId: string): readonly string[] => [ ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index b9affd8bd003..8cb066511e93 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -277,7 +277,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deps.requestTimeoutMs ) - supportsThreadGoal = (sessionId: string): boolean => this.sessions.has(sessionId) + // Provider-level: a goal change at rest starts the agent first. + supportsThreadGoal = (): boolean => true answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerCodexStructuredPrompt({ request, sessions: this.sessions }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 186127847a6d..b0d658178e04 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -48,7 +48,7 @@ let host: StructuredAgentSessionHost let acquire: Mock let dispatch: Mock let adapterExtras: Partial -let releaseGraceMs: number +let idleMs: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, @@ -80,7 +80,7 @@ async function startHost(): Promise { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs, + idleSweep: { intervalMs: 5, idleMs }, now: () => NOW }) } @@ -89,7 +89,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-accept-deliver-')) resetHostTestOperationIds() adapterExtras = {} - releaseGraceMs = 60_000 + idleMs = 60 * 60_000 acquire = vi.fn(spawnChild) dispatch = vi.fn(async () => ({ state: 'accepted' as const, @@ -154,8 +154,10 @@ function stop() { }) } -function submission(id: string): AgentJournalSubmission | undefined { - return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) } /** Read back after the conversation was closed, through the same open any reader takes. */ @@ -164,21 +166,19 @@ async function reopened(id: string): Promise return submission(id) } -function errorRows(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) +async function errorRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) } let subscriptions = 0 -function subscribe(): AgentSessionSubscribeEvent[] { +async function subscribe(): Promise { const events: AgentSessionSubscribeEvent[] = [] subscriptions += 1 // Cloned as received: a frame shares the journal's live objects, which later rows revise. - host.subscribe({ + await host.subscribe({ id: `sub-${subscriptions}`, sessionId: SESSION, emit: (event) => events.push(structuredClone(event)) @@ -263,14 +263,16 @@ describe('a send is answered at acceptance', () => { }) const id = await accept('hello') - const events = subscribe() - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + const events = await subscribe() + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) expect(dispatch).not.toHaveBeenCalled() starting.resolve() - await eventually(() => expect(framedStates(events, id)).toEqual(['handed-over'])) + await eventually(async () => expect(framedStates(events, id)).toEqual(['handed-over'])) answering.resolve() - await eventually(() => expect(framedStates(events, id)).toEqual(['handed-over', 'accepted'])) + await eventually(async () => + expect(framedStates(events, id)).toEqual(['handed-over', 'accepted']) + ) }) it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { @@ -281,7 +283,7 @@ describe('a send is answered at acceptance', () => { return spawnChild(input) }) const first = await accept('first') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) const second = host.send(CALLER, sendParams('second')) starting.resolve() @@ -290,11 +292,15 @@ describe('a send is answered at acceptance', () => { throw new Error('the second send was refused') } const secondId = secondResult.value.clientMessageId - await eventually(() => expect(submission(secondId)?.dispatchState).toBe('accepted')) + await eventually(async () => + expect((await submission(secondId))?.dispatchState).toBe('accepted') + ) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) // The second was accepted while the start held the queue — before the first was handed over. - expect(submission(secondId)!.submittedAt).toBeLessThanOrEqual(submission(first)!.handedOverAt!) - const rows = host.journalSnapshot(SESSION).submissions + expect((await submission(secondId))!.submittedAt).toBeLessThanOrEqual( + (await submission(first))!.handedOverAt! + ) + const rows = (await host.journalSnapshot(SESSION)).submissions expect(rows.map((row) => row.clientMessageId)).toEqual([first, secondId]) }) }) @@ -306,16 +312,16 @@ describe('a start the chat needed and did not get', () => { const first = await accept('first') const second = await accept('second') - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) - const rows = errorRows() + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + const rows = await errorRows() expect(rows).toHaveLength(1) expect(rows[0]).toContain('spawn codex ENOENT') - expect(submission(first)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) - expect(submission(second)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) + expect(await submission(second)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) const next = await accept('after the fix') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) - expect(errorRows()).toHaveLength(1) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) + expect(await errorRows()).toHaveLength(1) }) it.each([ @@ -345,11 +351,11 @@ describe('a start the chat needed and did not get', () => { await host.flushAllStreamedEvents() await startHost() const id = await accept('hello') - const events = subscribe() + const events = await subscribe() - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(errorRows()).toHaveLength(1) - expect(errorRows()[0]).toContain(cause) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await errorRows()).toHaveLength(1) + expect((await errorRows())[0]).toContain(cause) const framedRows = events.flatMap((event) => event.type === 'batch' || event.type === 'snapshot' ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( @@ -385,7 +391,7 @@ describe('a start the chat needed and did not get', () => { dispatchState: 'rejected', reason: expect.stringContaining('record store write failed') }) - expect(errorRows()).toEqual([settled?.reason]) + expect(await errorRows()).toEqual([settled?.reason]) }) }) @@ -411,7 +417,7 @@ describe('an attach that fails after indexing its child', () => { } return AgentSessionRecordStore.prototype.recordOperationOutcome.call(store, input) }) - await eventually(() => expect(submission(first)?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) // Nothing was indexed, so nothing had to be taken back: no list ever showed a child. expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(owned).not.toContain(true) @@ -419,7 +425,7 @@ describe('an attach that fails after indexing its child', () => { const next = await accept('after the failure') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(acquire).toHaveBeenCalledTimes(acquiresBefore + 1) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) }) @@ -433,7 +439,7 @@ describe('what an earlier host process left behind', () => { await host.revealSession(SESSION) - expect(submission('queued')).toMatchObject({ + expect(await submission('queued')).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_HOST_RESTARTED }) @@ -453,8 +459,8 @@ describe('what an earlier host process left behind', () => { await host.revealSession(SESSION) - expect(submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) - expect(submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) // Deciding them from provider history waits for a won lease (W4′d). expect(providerHistoryWindow).not.toHaveBeenCalled() expect(dispatch).not.toHaveBeenCalled() @@ -480,8 +486,8 @@ describe('a child that exits before its message is handed over', () => { const id = await accept('hello') - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)?.reason).toContain('codex app-server crashed') + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect((await submission(id))?.reason).toContain('codex app-server crashed') expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) @@ -496,7 +502,7 @@ describe('Stop withdraws what is queued', () => { // Stop's own open wakes the delivery loop, whose first step queues behind this Stop. expect(await stop()).toMatchObject({ ok: true }) - expect(submission('leftover')).toMatchObject({ + expect(await submission('leftover')).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) @@ -511,14 +517,14 @@ describe('Stop withdraws what is queued', () => { return spawnChild(input) }) const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) const stopped = stop() starting.resolve() expect(await stopped).toMatchObject({ ok: true }) - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - expect(submission(id)?.handedOverAt).toBeUndefined() + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + expect((await submission(id))?.handedOverAt).toBeUndefined() expect(dispatch).not.toHaveBeenCalled() }) @@ -537,7 +543,7 @@ describe('Stop withdraws what is queued', () => { providerChildPhase: 'starting' as const })) const id = await accept('hello') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await eventually(async () => expect(awaitStarted).toHaveBeenCalled()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -549,7 +555,7 @@ describe('Stop withdraws what is queued', () => { // A loop still waiting on that start would swallow this send; it starts a new child instead. awaitStarted.mockImplementation(async () => undefined) const next = await accept('after stop') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(1) }) }) @@ -560,9 +566,8 @@ describe('an eviction between acceptance and handover', () => { adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION) await startHost() - await host.hold(SESSION, 'surface-1') const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // Between the delivery loop's start step and its handover step. await host.close(SESSION) started.resolve() @@ -580,13 +585,12 @@ describe('an eviction between acceptance and handover', () => { adapterExtras = { awaitStarted } await host.close(SESSION) await startHost() - await host.hold(SESSION, 'surface-1') dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') - await eventually(() => expect(submission(handed)?.handedOverAt).toBeDefined()) + await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) awaitStarted.mockImplementation(() => second.promise) const queued = await accept('still queued') - await eventually(() => expect(awaitStarted).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) await host.close(SESSION) second.resolve() @@ -595,25 +599,24 @@ describe('an eviction between acceptance and handover', () => { dispatchState: 'rejected', reason: DISPATCH_REJECTED_PROVIDER_CLOSED }) - expect(submission(handed)).toMatchObject({ dispatchState: 'unknown' }) + expect(await submission(handed)).toMatchObject({ dispatchState: 'unknown' }) expect(dispatch).toHaveBeenCalledTimes(1) }) - it('does not evict an idle child while a message is still queued for it (W24)', async () => { - releaseGraceMs = 0 + it('does not stop an idle child while a message is still queued for it (W24)', async () => { + idleMs = 0 const started = deferred() adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION) await startHost() - await host.hold(SESSION, 'surface-1') const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - host.release(SESSION, 'surface-1') + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + // The idle sweep ticks every few milliseconds meanwhile. await new Promise((resolve) => setTimeout(resolve, 20)) started.resolve() // Handed to the child it was queued for; the eviction may follow once nothing is owed. - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(1)) expect(dispatch.mock.calls[0]?.[0].clientMessageId).toBe(id) expect(acquire).toHaveBeenCalledTimes(2) }) @@ -642,7 +645,7 @@ describe('a compaction or rewind an earlier child left prepared', () => { const id = await accept('after the compaction') - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ phase: 'committed', state: 'unknown' @@ -665,7 +668,7 @@ describe('a compaction or rewind an earlier child left prepared', () => { const id = await accept('after the rewind') - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'completed' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 566e41a66359..b209bbccb9e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -61,8 +61,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - rewindSupport: NonNullable = (sessionId) => - this.liveOwnerOrNull(sessionId)?.rewindSupport?.(sessionId) ?? { + rewindSupport: NonNullable = (sessionId, agent) => + this.capabilityOwner(sessionId, agent)?.rewindSupport?.(sessionId) ?? { supported: false, reason: 'unsupported' } @@ -94,11 +94,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - supportsThreadGoal = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.supportsThreadGoal?.(sessionId) ?? false + supportsThreadGoal = (sessionId: string, agent?: string): boolean => + this.capabilityOwner(sessionId, agent)?.supportsThreadGoal?.(sessionId) ?? false - recordsContextUsage = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.recordsContextUsage?.(sessionId) ?? false + recordsContextUsage = (sessionId: string, agent?: string): boolean => + this.capabilityOwner(sessionId, agent)?.recordsContextUsage?.(sessionId) ?? false stopBackgroundTasks: NonNullable = ( input @@ -216,6 +216,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return adapter } + /** The live owner, or for a session at rest the provider it would start under. */ + private capabilityOwner(sessionId: string, agent?: string): StructuredAgentSessionAdapter | null { + return this.liveOwnerOrNull(sessionId) ?? (agent ? this.adapterForAgent(agent) : null) + } + private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { const route = this.routes.get(sessionId) return route?.state === 'live' ? route.adapter : null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index f3e3252e4c36..337233977750 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -199,7 +199,8 @@ export type StructuredAgentSessionAdapter = { /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise }): Promise - rewindSupport?(sessionId: string): AgentSessionRewindSupport + /** `agent` answers for a session with no child running, from the provider alone. */ + rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string fence: number @@ -250,10 +251,10 @@ export type StructuredAgentSessionAdapter = { * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** Whether this live session can change its goal. */ - supportsThreadGoal?(sessionId: string): boolean - /** Whether this live session writes context facts to its turn rows. */ - recordsContextUsage?(sessionId: string): boolean + /** Whether this session can change its goal; `agent` answers one at rest. */ + supportsThreadGoal?(sessionId: string, agent?: string): boolean + /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ + recordsContextUsage?(sessionId: string, agent?: string): boolean stopBackgroundTasks?(input: { sessionId: string fence: number diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts similarity index 66% rename from src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 9ea59e18487c..09eb1185ceae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -1,11 +1,9 @@ // Giving a session its provider child back. // -// This is the replacement for the startup resume, and the difference is only in WHO asks: the same -// eligibility rule, run when a surface binds, when a send finds the owner gone, or when a child -// exits under an open surface — never when the app launches. It runs inside the session's +// Only work starts one: the delivery loop for a queued message, and the few operations that need +// the provider itself — never a view, and never the app launching. It runs inside the session's // serialize, with the attach it is given, so the eligibility it reads is the one the attach acts -// on. A write-capable hold must fail when acquisition is refused so the surface never mistakes a -// readable journal for a live provider child. +// on. import type { AgentSessionAttachResult, @@ -14,10 +12,7 @@ import type { } from '../../../shared/agent-session-wire' import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { - attachStructuredAgentSessionUnderSerialize, - type StructuredAgentSessionAttachOptions -} from './structured-agent-session-attach-orchestration' +import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { @@ -31,14 +26,53 @@ export type StructuredAgentSessionResumeOutcome = | { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } -export async function resumeHeldStructuredAgentSession(input: { +/** The attach's caller key: the ledger row a start settles is Orca's own. */ +const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' + +/** + * Gives the session a provider child if it has none, for a caller inside its serialize — which is + * what makes "if it has none" exact: two askers run this in turn, and the second finds the first + * one's child. A failed start leaves the next asker to make its own. + */ +export async function ensureStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, + sessionId: string +): Promise { + if (context.sessions.get(sessionId)?.child) { + return { ok: true } + } + const started = await startStructuredAgentSessionAgent(context, sessionId) + if (!started.ok || context.sessions.get(sessionId)?.child) { + return started + } + return { + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'The session attached without a provider child to write to.' + } + } +} + +/** The same, for an operation's admission: a start that throws is that operation's refusal. */ +export function ensureStructuredAgentSessionAgentForOperation( + context: StructuredAgentSessionAttachContext, + sessionId: string +): Promise { + return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => ({ + ok: false, + refusal: { + code: 'agent_session_owner_restart_failed', + message: error instanceof Error ? error.message : String(error) + } + })) +} + +async function startStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, sessionId: string - context: StructuredAgentSessionAttachContext - /** Who is asking; the attach keys the ledger row it settles by it. */ - callerKey: string - attachOptions?: StructuredAgentSessionAttachOptions -}): Promise { - const { sessionId, context, callerKey } = input +): Promise { + const callerKey = AGENT_START_CALLER_KEY // The record is read only once this host has adjudicated it and exited any recovery stage a // failed attempt latched — a lease left in `manual-recovery` by an unproven exit is one the // resolver hands back, and the eligibility below must see it that way. @@ -76,12 +110,7 @@ export async function resumeHeldStructuredAgentSession(input: { } let attached: AgentSessionMutationResult try { - attached = await attachStructuredAgentSessionUnderSerialize( - context, - callerKey, - params, - input.attachOptions - ) + attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params) } catch (error) { // The attach settles an acquisition that failed — the ledger row, the released lease — before // it rethrows the cause. That row is the answer: a failure it recorded is this resume's diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 6941d5f72616..f792ac588d6a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -38,9 +38,7 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** Paired with `sessions.delete` by `forgetStructuredAgentSession`, which a close runs. */ - forgetStatus: (sessionId: string) => void - publishStatus?: (sessionId: string) => void + publishStatus: (sessionId: string) => void /** The conversation's one open journal, opened when closed; see `conversation-open`. */ openConversation: (sessionId: string) => Promise } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 743152b76b69..af59cac448d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -43,17 +43,15 @@ import { } from '../../observability/agent-session-instrumentation' export type StructuredAgentSessionAttachOptions = { - /** Provider-exit recovery: refuses once the ticket the restart was issued for is stale. */ - admitRecoveryTicket?: () => boolean recordPhase?: AgentSessionCreatePhaseRecorder } /** * The attach itself, for a caller already inside the session's serialize. * - * That is every caller that has to know what the session looks like RIGHT NOW: a hold, a send - * making sure it has an owner, provider-exit recovery. They run their - * check and this attach in one serialized step, so "the session has no child" is still true when + * That is every caller that has to know what the session looks like RIGHT NOW: the delivery loop, + * and an operation that needs the provider. They run their check and this attach in one serialized + * step, so "the session has no child" is still true when * the attach starts. `attachStructuredAgentSession` is this under `serialize`, for a client. */ export function attachStructuredAgentSessionUnderSerialize( @@ -106,12 +104,6 @@ async function runAttach( const fenceBefore = context.sessions.has(sessionId) ? structuredAgentSessionConversationFence(context.deps.store, sessionId) : null - if (options.admitRecoveryTicket && !options.admitRecoveryTicket()) { - return refuseAgentSessionMutation({ - code: 'agent_session_checkpoint_stale', - message: 'The provider-exit recovery ticket is no longer current.' - }) - } const unreconciled = await withAgentSessionCreatePhase('reconcile_leases', recordPhase, () => context.reconcileLeases(sessionId) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 8594b6d58baa..a88949a296f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -19,15 +19,18 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly deps: StructuredAgentSessionHostDeps, private readonly sessions: Map, private readonly subscribers: AgentSessionSubscribers, - private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession, + /** The host's accessor: opens a conversation at rest, and never starts an agent. */ + private readonly conversation: ( + sessionId: string + ) => Promise, /** Task edges change the status summary too; the feed's equality check * keeps a no-op re-projection from reaching subscribers. */ private readonly onPublished: (sessionId: string) => void ) {} - history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { + async history(request: AgentSessionHistoryRequest): Promise { const result = readStructuredAgentSessionHistoryResult({ - journal: this.requireSession(request.sessionId).journal, + journal: (await this.conversation(request.sessionId)).journal, record: this.deps.store.getRecord(request.sessionId), request }) @@ -43,8 +46,9 @@ export class StructuredAgentSessionBackgroundTaskChannel { } } - subscribe(input: AgentSessionSubscribeInput): () => void { - const session = this.requireSession(input.sessionId) + /** Resolves once the conversation is open and the subscriber holds its opening frame. */ + async subscribe(input: AgentSessionSubscribeInput): Promise<() => void> { + const session = await this.conversation(input.sessionId) const backgroundTasks = this.state(input.sessionId) return this.subscribers.open({ ...input, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 537a2ac695f3..4a8f0a1440f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -11,7 +11,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { evictHeldStructuredAgentSession } from './structured-agent-session-host-lifetime' +import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -24,7 +24,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe('Claude root-exit eviction', () => { +describe('Claude root-exit stop', () => { it('releases a captured live claim after the provider root exits', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-claude-root-exit-')) roots.push(root) @@ -78,6 +78,7 @@ describe('Claude root-exit eviction', () => { journalDir: join(root, 'journal') }) const close = vi.spyOn(journal, 'close') + const publishStatus = vi.fn() const params: AgentSessionAttachParams = { envelope: { sessionId: 'session-1', @@ -116,13 +117,13 @@ describe('Claude root-exit eviction', () => { claude.connections[0]!.handlers.onExit?.(new Error('provider exited')) await expect( - evictHeldStructuredAgentSession( + stopStructuredAgentSessionAgentUnderSerialize( { deps, runtimeState, sessions, now: () => NOW + 30 * 60_000, - forgetStatus: vi.fn() + publishStatus }, 'session-1' ) @@ -133,8 +134,10 @@ describe('Claude root-exit eviction', () => { ownerProcess: null, deathEvidence: { kind: 'exit-observed' } }) - expect(sessions.size).toBe(0) - expect(close).toHaveBeenCalledOnce() + // The agent went to rest; the conversation stays open and listed. + expect(sessions.get('session-1')?.child).toBeNull() + expect(close).not.toHaveBeenCalled() + expect(publishStatus).toHaveBeenCalledWith('session-1') // The adapter agrees the session is over: nothing is left to refuse the next start. await expect(adapter.closeSession('session-1')).resolves.toBe(true) }) 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 7681e0f34362..06bda8203828 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 @@ -62,10 +62,16 @@ export class StructuredAgentSessionClientDelivery { subscriber: StructuredAgentSessionTurnCompletionSubscriber ): (() => void) => this.turnCompletionFeed.subscribe(subscriber) - closeSession(sessionId: string): void { + /** The conversation's handle closed. Its status row stays in every session list; the + * agent-status store keeps it too while the chat still has a tab to show it in. */ + closeSession(sessionId: string, options: { listed: boolean }): void { this.sendSettlement.closeSession(sessionId) - this.statusFeed.close(sessionId) - // The next attach re-baselines rather than announcing the turn it was already holding. + if (options.listed) { + this.statusFeed.revokeLive(sessionId) + } else { + this.statusFeed.close(sessionId) + } + // The next open re-baselines rather than announcing the turn it was already holding. this.turnCompletionFeed.forget(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts index 303ecbe92fc0..f5410de74f62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts @@ -135,7 +135,7 @@ function attachContext( reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), now: () => 1, - forgetStatus: () => undefined + publishStatus: () => undefined } as unknown as StructuredAgentSessionAttachContext } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts new file mode 100644 index 000000000000..835dcbd7be14 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -0,0 +1,301 @@ +// Stopping an agent and closing its conversation's handle are two things, and a reader, a session +// list and a send each see only the one that happened. Ticks are driven by hand. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + IDLE_MS, + readerSaw, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + sweepOnce, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +function fence(): number { + return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 +} + +function openSession(): StructuredAgentSessionHostSession | undefined { + const sessions: unknown = Reflect.get(rig.host, 'sessions') + return sessions instanceof Map ? sessions.get(SESSION) : undefined +} + +function statusRows(): AgentSessionStatusSummary[] { + return rig.statusEvents + .flatMap((event) => (event.type === 'status' ? [event.session] : [])) + .filter((summary) => summary.sessionId === SESSION) +} + +describe('a stop that fails', () => { + it('is retried on the next tick (P2-11 a)', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockResolvedValueOnce(false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(openSession()?.owesProviderChildWindDown).toMatchObject({ generation: expect.any(String) }) + await sweepOnce(rig.host) + + expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() + }) + + it('finishes its wind-down on the next tick, whatever its own writes did to the clock (P2-11 b)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + // The child is proven gone and its work settled, then handing the lease back fails. + const transition = vi + .spyOn(rig.store, 'transitionHandoff') + .mockRejectedValueOnce(new Error('store unavailable')) + + await sweepOnce(rig.host) + expect(transition).toHaveBeenCalledOnce() + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + // Five minutes later, not thirty. + rig.clock.now += 5 * 60_000 + await sweepOnce(rig.host) + + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(rig.adapter.closeSession).toHaveBeenCalledOnce() + }) +}) + +describe('closing the handle', () => { + it('keeps a tabbed chat listed and its reader open, and a send reopens it for that reader (P2-14)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + // The row stays in the agent-status store, idle and no longer running here, and a session + // list that connects now still gets it. + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) + expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() + const late: { type: string; sessions?: AgentSessionStatusSummary[] }[] = [] + rig.host.subscribeStatus({ id: 'late', emit: (event) => late.push(event) }) + expect(late[0]?.sessions?.map((summary) => summary.sessionId)).toContain(SESSION) + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + + const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + // The reader opened on the first handle is fed by the second. + await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) + }) + + it('drops the agent-status row of a chat whose tab is gone (P2-14, retired)', async () => { + await foundRestTestChat(rig) + await rig.store.setSessionTabVisibility(SESSION, false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.sink.forget).toHaveBeenCalled() + }) + + it('forgets the row of a resting chat once its tab closes (P2-32)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + await sweepOnce(rig.host) + expect(rig.sink.forget).not.toHaveBeenCalled() + + await rig.host.setSessionTabVisibility(SESSION, false) + expect(rig.sink.forget).toHaveBeenCalled() + }) + + it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { + await foundRestTestChat(rig) + + await rig.host.close(SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + // The stop says not-running; the row belongs to the tab, so nothing forgets it. + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) + expect(rig.sink.publish.mock.calls.at(-1)?.[0].hostExecutionOwned).toBeUndefined() + + await rig.host.setSessionTabVisibility(SESSION, false) + expect(rig.sink.forget).toHaveBeenCalledOnce() + }) + + it('never hands a reader a handle it is closing (P2-25)', async () => { + await foundRestTestChat(rig) + const journal = openSession()?.journal + if (!journal) { + throw new Error('the conversation should be open') + } + const closing = Promise.withResolvers() + const gate = Promise.withResolvers() + const close = journal.close.bind(journal) + vi.spyOn(journal, 'close').mockImplementation(async () => { + await close() + closing.resolve() + await gate.promise + }) + rig.clock.now += IDLE_MS + 1 + const tick = sweepOnce(rig.host) + await closing.promise + + const history = rig.host.history({ sessionId: SESSION, direction: 'tail' }) + const facts = readStructuredSessionGateFacts(SESSION) + gate.resolve() + await tick + + const page = await history + expect(page.ok && page.page.items.length).toBeGreaterThan(0) + await expect(facts).resolves.not.toBeNull() + // And the handle a write finds is a live one. + const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + }) + + it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + const tick = sweepOnce(rig.host) + const within = (work: Promise) => + Promise.race([ + work, + new Promise((_, reject) => setTimeout(() => reject(new Error('wedged')), 2_000)) + ]) + const sent = within(rig.host.send(CALLER, restTestSend('right after', fence()))) + + await within(tick) + expect(await sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + await expect(within(sweepOnce(rig.host))).resolves.toBeUndefined() + }) +}) + +describe('the sweep and the lease (P2-20)', () => { + it('reads only open conversations and never probes or resolves a lease', async () => { + await foundRestTestChat(rig) + const probeOwner = vi.fn(async () => ({ outcome: 'pid-absent' as const })) + await rig.restart({ probeOwner }) + setStructuredAgentSessionHost(rig.host) + await rig.store.transitionHandoff(SESSION, (current) => ({ + ...current, + lease: { ...current.lease, handoffStage: 'recovering' } + })) + const resolveRecovery = vi.spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private runtime state, spied to prove the sweep never resolves a lease. + Reflect.get(rig.host, 'runtimeState') as { + resolveRecovery: (id: string) => Promise + }, + 'resolveRecovery' + ) + // Open, at rest, on a lease still recovering. + await rig.host.journalSnapshot(SESSION) + expect(rig.host.hasSession(SESSION)).toBe(true) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(probeOwner).not.toHaveBeenCalled() + expect(resolveRecovery).not.toHaveBeenCalled() + }) +}) + +describe('a start that never finishes (P2-15)', () => { + it('is stopped by the sweep, its queued message rejected with why, and the chat still sends', async () => { + const started = Promise.withResolvers() + rig.adapter.closeSession.mockImplementation(async () => { + started.resolve() + return true + }) + const spawn = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementationOnce(async (input) => ({ + ...(await spawn(input)), + providerChildPhase: 'starting' as const + })) + Object.assign(rig.host.deps.adapter, { + awaitStarted: () => started.promise + }) + const reader = collectSubscriber() + const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + expect(attached.ok).toBe(true) + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) + expect(sent.ok).toBe(true) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + await vi.waitFor(() => + expect(readerSaw(reader.events).submissions).toContainEqual( + expect.objectContaining({ + dispatchState: 'rejected', + reason: 'Codex never finished starting, so Orca stopped it.' + }) + ) + ) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + + const again = await rig.host.send(CALLER, restTestSend('try again', fence())) + expect(again.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + await expect(sweepOnce(rig.host)).resolves.toBeUndefined() + }) +}) + +describe('the wind-down retry with a message queued (P2-31)', () => { + it('waits for the delivery rather than rejecting a message accepted after the failed stop', async () => { + const queued = { clientMessageId: 'm', dispatchState: 'pending', handoverRecorded: true } + const session = { + journal: { + submissions: () => [queued], + pendingSubmissions: () => [queued], + snapshot: () => ({ items: [] }) + }, + child: null, + owesProviderChildWindDown: { generation: 'generation-1', fence: 1 } + } + const stopAgent = vi.fn(async () => undefined) + const sweep = new StructuredAgentSessionIdleSweep({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. + sessions: Object.assign(new Map([[SESSION, session as never]]), { + lastActivityAt: () => 0 + }), + serialize: (_id, task) => task(), + now: () => IDLE_MS + 1, + isDisposed: () => false, + deliveryActive: () => true, + backgroundTaskState: () => undefined, + hasOpenDispatch: () => false, + stopAgent, + stopStartingAgent: stopAgent, + closeConversation: vi.fn(async () => false), + onError: (_id, error) => { + throw error + } + }) + await sweep.tick() + expect(stopAgent).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts new file mode 100644 index 000000000000..a71fa103cbb8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -0,0 +1,132 @@ +// How a conversation is reached, closed and swept — the host's lifetime rules in one place. +// +// A conversation is reached only through `conversation`, which opens it at rest and starts no +// agent. It closes when its tab closes or the idle sweep finds its handle is only a cache. Every +// public entry point here takes the session's serialize once and calls the under-serialize forms, +// because the queue is not reentrant. + +import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { + abandonQueuedStructuredAgentSessionMessages, + closeStructuredAgentSessionConversationUnderSerialize, + stopStructuredAgentSessionAgentUnderSerialize, + type StructuredAgentSessionLifetimeContext +} from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' + +export type StructuredAgentSessionConversationLifetime = ReturnType< + typeof createStructuredAgentSessionConversationLifetime +> + +export function createStructuredAgentSessionConversationLifetime(host: { + /** Resolved per call: the host's collaborators are assigned after this is built. */ + context: () => StructuredAgentSessionLifetimeContext + sessions: StructuredAgentSessionConversations + serialize: (sessionId: string, task: () => Promise) => Promise + /** PR 1's one open function, for a caller inside the session's serialize. */ + open: (sessionId: string) => Promise + deliveryActive: (sessionId: string) => boolean + /** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */ + closeStatus: (sessionId: string, options: { listed: boolean }) => void +}) { + let disposed = false + const { sessions, serialize } = host + const deps = () => host.context().deps + // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. + const stopAgent = (sessionId: string) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) + + const closeConversation = (sessionId: string): Promise => + closeStructuredAgentSessionConversationUnderSerialize( + { + sessions, + closeStatus: (id) => { + const tabs = deps().store.getVisibleSessionTabIndex() + // A legacy store cannot say, so the row stays; restart is the boundary that forgets. + host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) + } + }, + sessionId + ) + + const idleSweep = new StructuredAgentSessionIdleSweep({ + sessions, + serialize, + now: () => host.context().now(), + isDisposed: () => disposed, + deliveryActive: host.deliveryActive, + backgroundTaskState: (sessionId) => deps().adapter.backgroundTaskState?.(sessionId), + hasOpenDispatch: (sessionId) => { + const record = deps().store.getRecord(sessionId) + return record !== null && deps().hasOpenDispatch?.(record) === true + }, + stopAgent, + // A host stop with its reason: the delivery loop waiting on this child writes the one error + // row and rejects what is queued with it. + stopStartingAgent: (sessionId) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'host-stop', + reason: `${TUI_AGENT_DISPLAY_NAMES[sessions.get(sessionId)?.params.provider ?? 'claude']} never finished starting, so Orca stopped it.` + }), + closeConversation, + onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }), + ...deps().idleSweep + }) + + return { + idleSweep, + stopAgent, + /** Quit has begun: nothing opens a conversation or sweeps one after this. */ + dispose: (): void => { + disposed = true + idleSweep.dispose() + }, + /** + * The only way any code reaches a session. An open conversation answers without the lock, so + * a read never waits behind a start; a closed one is opened once, under it. Nothing here + * touches the lease or starts a child. Use the result before the next `await`: a close can + * drop it after. + */ + conversation: async (sessionId: string): Promise => { + const open = sessions.get(sessionId) + if (open) { + return open + } + if (disposed) { + throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + } + const record = deps().store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + if (!adapterSupportsRecord(deps().adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } + return serialize(sessionId, async () => { + const session = await host.open(sessionId) + if (!session) { + throw new Error('agent_session_identity_required') + } + return session + }) + }, + /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is + * still queued will not be sent. */ + close: (sessionId: string): Promise => + serialize(sessionId, async () => { + const session = sessions.get(sessionId) + if (session) { + // Abandoned before the stop, so no start delivers it. + await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) + } + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'evict' + }) + await closeConversation(sessionId) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index 7014e3947283..b294540990d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -54,7 +54,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) @@ -70,7 +71,11 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() // Collaborators hold the host's map as a plain `Map`; `set` still reaches the binding. const sessions: Map = - new StructuredAgentSessionConversations({ deliver, onDeliveryError: vi.fn() }) + new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn(), + now: () => 0 + }) const journal = await openJournal('a') sessions.set('session-1', session(journal)) @@ -83,7 +88,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) @@ -97,7 +103,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const replaced = await openJournal('a') const current = await openJournal('b') @@ -115,7 +122,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) @@ -133,7 +141,8 @@ describe('a conversation delivers what its journal commits', () => { deliver: () => { throw failure }, - onDeliveryError + onDeliveryError, + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index 2f38ddf4da25..6fcaef07483a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -8,15 +8,21 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi * * Delivery runs a microtask after the commit, so a reader that throws cannot fail a write that is * already durable. A handle this map has since replaced or dropped delivers nothing. + * + * Each entry also carries when it last saw activity — its open, then every journal publish — which + * is the idle sweep's clock. In memory only, so it dies with the entry. */ export class StructuredAgentSessionConversations extends Map< string, StructuredAgentSessionHostSession > { + private readonly activity = new Map() + constructor( private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void onDeliveryError: (sessionId: string, error: unknown) => void + now: () => number } ) { super() @@ -42,6 +48,22 @@ export class StructuredAgentSessionConversations extends Map< } }) }) + this.activity.set(sessionId, this.delivery.now()) return super.set(sessionId, session) } + + override delete(sessionId: string): boolean { + this.activity.delete(sessionId) + return super.delete(sessionId) + } + + touch(sessionId: string): void { + if (this.has(sessionId)) { + this.activity.set(sessionId, this.delivery.now()) + } + } + + lastActivityAt(sessionId: string): number | undefined { + return this.activity.get(sessionId) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 936fc639015c..bad59b0c4c81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -16,7 +16,7 @@ import { providerExitBeforeDeliveryRejection, providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 4905cc192576..0c2a76e05a57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -7,12 +7,8 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHolds } from './structured-agent-session-holds' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' -import { - isStructuredAgentSessionRecoveryTicketCurrent, - settleUnexpectedStructuredAgentSessionExit -} from './structured-agent-session-unexpected-exit' +import { settleUnexpectedStructuredAgentSessionExit } from './structured-agent-session-unexpected-exit' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -25,12 +21,8 @@ export class StructuredAgentSessionEventRecovery { flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void publishStatus?: (sessionId: string) => void - hasResumeCapableHolder: (sessionId: string) => boolean - restartReleaseGrace: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** The one restart every asker shares; the holds put an unheld child on the idle clock. */ - ensureProviderChild: StructuredAgentSessionHolds['ensureProviderChild'] onBarrierError: (sessionId: string, error: unknown) => void } ) {} @@ -67,34 +59,12 @@ export class StructuredAgentSessionEventRecovery { .finally(() => this.sinkFailures.delete(sessionId)) } + /** An exit is settled and shown; nothing restarts the child. The next send does, through the + * delivery loop, which also owns any message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } - const ticket = await settleUnexpectedStructuredAgentSessionExit(this.context, event) - if (!ticket) { - return - } - // One serialized step with the ticket check inside it: a hold or a send that got there first - // has already replaced the owner, and this step finds that child and attaches nothing — or, - // once the lease has moved on, refuses on the stale ticket rather than spawning a second child. - try { - const resumed = await this.context.serialize(ticket.sessionId, () => - this.context.ensureProviderChild(ticket.sessionId, { - admitRecoveryTicket: () => - isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket) - }) - ) - if (!resumed.ok && isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket)) { - this.context.onBarrierError( - ticket.sessionId, - new Error(`${resumed.refusal.code}: ${resumed.refusal.message}`) - ) - } - } catch (error) { - if (isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket)) { - this.context.onBarrierError(ticket.sessionId, error) - } - } + await settleUnexpectedStructuredAgentSessionExit(this.context, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 1ffc08a02493..90e07ddf88ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -186,7 +186,7 @@ function attachContext( reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), now: () => 1, - forgetStatus: (sessionId: string) => feed.forget(sessionId) + publishStatus: (sessionId: string) => feed.publish(sessionId) } as unknown as StructuredAgentSessionAttachContext } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index bbc1ec49d4c4..9ce3a6f0787f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -96,7 +96,7 @@ async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null if (!appended) { throw new Error('provider question was not written to the journal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts deleted file mode 100644 index 974a69b1c3c8..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' - -const GRACE_MS = 15_000 -const pendingHolds: StructuredAgentSessionHolds[] = [] - -/** The host's per-session queue: a second hold waits for the attach the first one is running. */ -function keyedSerialize() { - const chains = new Map>() - return (sessionId: string, task: () => Promise) => - runKeyedSerializedOperation(chains, sessionId, task) -} - -function resumeHarness() { - const resumeGate = Promise.withResolvers() - let child = false - let turnActive = false - const evict = vi.fn(async () => { - child = false - }) - const resume = vi.fn(async () => { - await resumeGate.promise - child = true - return { ok: true as const } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => turnActive, - evict, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - return { - holds, - resume, - resumeGate, - evict, - hasChild: () => child, - setTurnActive: (value: boolean) => { - turnActive = value - } - } -} - -beforeEach(() => vi.useFakeTimers()) -afterEach(() => { - for (const holds of pendingHolds.splice(0)) { - holds.dispose() - } - vi.useRealTimers() -}) - -describe('a surface leaving while its structured session resumes', () => { - it('releases the acquired child after the last surface disconnects during resume', async () => { - const { holds, resumeGate, evict, hasChild } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - - holds.release('session-1', 'connection-1:chat') - expect(holds.isReleasePending('session-1')).toBe(false) - resumeGate.resolve() - await hold - - expect(hasChild()).toBe(true) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(true) - await vi.advanceTimersByTimeAsync(GRACE_MS - 1) - expect(evict).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - expect(hasChild()).toBe(false) - }) - - it('waits for an active turn before releasing the late child', async () => { - const { holds, resumeGate, evict, setTurnActive } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - setTurnActive(true) - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(evict).not.toHaveBeenCalled() - expect(holds.isReleasePending('session-1')).toBe(true) - - setTurnActive(false) - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it.each([false, true])('preserves an arriving holder with resume=%s', async (resume) => { - const { holds, resumeGate, evict } = resumeHarness() - const first = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - const replacement = holds.hold('session-1', 'connection-2:chat', { resume }) - resumeGate.resolve() - await Promise.all([first, replacement]) - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - - holds.release('session-1', 'connection-2:chat') - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it('cancels the late-child release when a surface reconnects during grace', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - resumeGate.resolve() - await hold - expect(holds.isReleasePending('session-1')).toBe(true) - - await holds.hold('session-1', 'connection-2:chat') - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('preserves a failed resume without scheduling eviction', async () => { - const { holds, resumeGate, evict, hasChild } = resumeHarness() - const failure = new Error('provider acquisition failed') - const hold = holds.hold('session-1', 'connection-1:chat') - const rejected = expect(hold).rejects.toBe(failure) - holds.release('session-1', 'connection-1:chat') - resumeGate.reject(failure) - await rejected - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(hasChild()).toBe(false) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('leaves late acquisition cleanup to host teardown after disposal', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - holds.dispose() - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('does not restart release timers when a surface leaves after disposal', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - resumeGate.resolve() - await hold - holds.dispose() - holds.release('session-1', 'connection-1:chat') - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('releases a late child acquired after explicit close forgot its holders', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.forget('session-1') - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(holds.isHeld('session-1')).toBe(false) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it('lets a holder that left and came back make its own attempt behind a failing one, and its failure releases it', async () => { - const { holds, resume, resumeGate, evict } = resumeHarness() - const first = holds.hold('session-1', 'same-holder') - const firstRejected = expect(first).rejects.toThrow('acquisition failed') - holds.release('session-1', 'same-holder') - const replacement = holds.hold('session-1', 'same-holder') - const replacementRejected = expect(replacement).rejects.toThrow('acquisition failed') - await vi.advanceTimersByTimeAsync(0) - expect(holds.isHeld('session-1')).toBe(true) - expect(resume).toHaveBeenCalledOnce() - - // The gate stays rejected, so the replacement's own attempt fails the same way. - resumeGate.reject(new Error('acquisition failed')) - await Promise.all([firstRejected, replacementRejected]) - - expect(resume).toHaveBeenCalledTimes(2) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(evict).not.toHaveBeenCalled() - }) - - it('keeps a re-hold that finds the child the failing attempt ahead of it left behind', async () => { - const gate = Promise.withResolvers() - let child = false - const holds = new StructuredAgentSessionHolds({ - // The child is up before the attempt settles, and then the attempt fails behind it. - resume: async () => { - child = true - await gate.promise - return { ok: true as const } - }, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - const first = holds.hold('session-1', 'same-holder') - const rejected = expect(first).rejects.toThrow('acquisition failed') - holds.release('session-1', 'same-holder') - const replacement = holds.hold('session-1', 'same-holder') - - gate.reject(new Error('acquisition failed')) - await rejected - // The first hold's failure released only the holder it added, at the incarnation it added; - // the replacement then ran, found the child, and kept the holder it re-took. - await replacement - - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - }) - - it('starts a fresh resume once the failed one has settled', async () => { - let child = false - const resume = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('first acquisition failed')) - .mockImplementationOnce(async () => { - child = true - return { ok: true } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - - await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow('first acquisition failed') - await holds.hold('session-1', 'chat-1') - - expect(resume).toHaveBeenCalledTimes(2) - expect(holds.isHeld('session-1')).toBe(true) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts deleted file mode 100644 index 531717168b17..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Who WANTS this session alive, as a set of ids rather than a count. -// -// A refcount is the obvious shape and the wrong one. Every path that decrements it — a chat tab -// closing, a transport dying, a client retrying a release it already sent — can fire twice or not -// at all, and an integer cannot tell those apart: a duplicate release evicts a session somebody is -// still looking at, and a lost one leaks the child forever. A set answers both idempotently, -// because it records WHICH surface holds the session, not how many do. - -type Holder = { resumeCapable: boolean; incarnation: symbol } - -export class StructuredAgentSessionHolders { - private readonly bySession = new Map>() - - /** True when the session gained its FIRST holder — the edge that ends a pending release. */ - add(sessionId: string, holderId: string, resumeCapable = true): boolean { - const holders = this.bySession.get(sessionId) - if (!holders) { - this.bySession.set(sessionId, new Map([[holderId, { resumeCapable, incarnation: Symbol() }]])) - return true - } - const previous = holders.get(holderId) - holders.set(holderId, { - resumeCapable: (previous?.resumeCapable ?? false) || resumeCapable, - incarnation: previous?.incarnation ?? Symbol() - }) - return false - } - - /** True when the session lost its LAST holder — the edge that starts one. */ - remove(sessionId: string, holderId: string, expectedIncarnation?: symbol): boolean { - const holders = this.bySession.get(sessionId) - if ( - expectedIncarnation !== undefined && - holders?.get(holderId)?.incarnation !== expectedIncarnation - ) { - return false - } - if (!holders?.delete(holderId) || holders.size > 0) { - return false - } - this.bySession.delete(sessionId) - return true - } - - isHeld(sessionId: string): boolean { - return (this.bySession.get(sessionId)?.size ?? 0) > 0 - } - - has(sessionId: string, holderId: string): boolean { - return this.bySession.get(sessionId)?.has(holderId) ?? false - } - - incarnation(sessionId: string, holderId: string): symbol | undefined { - return this.bySession.get(sessionId)?.get(holderId)?.incarnation - } - - holderIds(sessionId: string): string[] { - return [...(this.bySession.get(sessionId)?.keys() ?? [])] - } - - hasResumeCapableHolder(sessionId: string): boolean { - return [...(this.bySession.get(sessionId)?.values() ?? [])].some( - (holder) => holder.resumeCapable - ) - } - - /** Drops every holder of one session without evaluating the edge, for a session that is gone. */ - forget(sessionId: string): void { - this.bySession.delete(sessionId) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts deleted file mode 100644 index 9b6718e99575..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -// The parts a session's lifetime is assembled from: the holder set, the release clock, and the -// deadline that keeps teardown from hanging. - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' -import { StructuredAgentSessionHolders } from './structured-agent-session-holders' -import { StructuredAgentSessionReleaseClock } from './structured-agent-session-release-clock' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' -import { - STRUCTURED_AGENT_SESSION_EVICTION_STEPS, - evictStructuredAgentSession -} from './structured-agent-session-eviction' -import { - StructuredAgentSessionEvictionTimeoutError, - withStructuredAgentSessionEvictionDeadline -} from './structured-agent-session-eviction-deadline' - -const clocks: StructuredAgentSessionReleaseClock[] = [] - -/** The host's per-session queue, so a hold and a writer really take turns. */ -function keyedSerialize() { - const chains = new Map>() - return (sessionId: string, task: () => Promise) => - runKeyedSerializedOperation(chains, sessionId, task) -} - -function clock(deps: { - hasOwedWork?: () => boolean - isHeld?: () => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void -}): StructuredAgentSessionReleaseClock { - const created = new StructuredAgentSessionReleaseClock({ - hasOwedWork: deps.hasOwedWork ?? (() => false), - isHeld: deps.isHeld ?? (() => false), - evict: deps.evict, - ...(deps.onError ? { onError: deps.onError } : {}), - graceMs: 1 - }) - clocks.push(created) - return created -} - -afterEach(() => { - for (const created of clocks.splice(0)) { - created.dispose() - } -}) - -describe('the holder set', () => { - it('reports the first and last holder, and nothing in between', () => { - const holders = new StructuredAgentSessionHolders() - - expect(holders.add('session-1', 'a')).toBe(true) - expect(holders.add('session-1', 'b')).toBe(false) - expect(holders.remove('session-1', 'a')).toBe(false) - expect(holders.remove('session-1', 'b')).toBe(true) - expect(holders.isHeld('session-1')).toBe(false) - }) - - // The reason this is a set and not a count: every release path can fire twice or not at all. - it('absorbs a duplicate hold and a duplicate release', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'a') - holders.add('session-1', 'a') - expect(holders.remove('session-1', 'a')).toBe(true) - expect(holders.remove('session-1', 'a')).toBe(false) - expect(holders.holderIds('session-1')).toEqual([]) - }) - - it('keeps one session holders out of another session holders', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'a') - holders.add('session-2', 'a') - holders.remove('session-1', 'a') - - expect(holders.isHeld('session-1')).toBe(false) - expect(holders.isHeld('session-2')).toBe(true) - }) - - it('distinguishes retaining holders from holders that may resume a provider', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'subscriber', false) - expect(holders.hasResumeCapableHolder('session-1')).toBe(false) - - holders.add('session-1', 'chat', true) - expect(holders.hasResumeCapableHolder('session-1')).toBe(true) - holders.remove('session-1', 'chat') - expect(holders.hasResumeCapableHolder('session-1')).toBe(false) - }) -}) - -describe('the release clock', () => { - it('waits out a running turn instead of evicting into it', async () => { - const evict = vi.fn(async () => {}) - let turnRunning = true - const releasing = clock({ hasOwedWork: () => turnRunning, evict }) - - releasing.arm('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - expect(evict).not.toHaveBeenCalled() - - turnRunning = false - await vi.waitFor(() => expect(evict).toHaveBeenCalledWith('session-1')) - }) - - it('stands down when a holder arrives during the wait', async () => { - const evict = vi.fn(async () => {}) - const releasing = clock({ isHeld: () => true, evict }) - - releasing.arm('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - - expect(evict).not.toHaveBeenCalled() - }) - - it('keeps an idle unheld child for thirty minutes, and activity starts the window over', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - try { - const evict = vi.fn(async () => {}) - const idle = new StructuredAgentSessionReleaseClock({ - hasOwedWork: () => false, - isHeld: () => false, - evict - }) - clocks.push(idle) - const minutes = (count: number) => vi.advanceTimersByTimeAsync(count * 60_000) - - idle.arm('session-1') - await minutes(20) - idle.renew('session-1') - await minutes(29) - expect(evict).not.toHaveBeenCalled() - - await minutes(1) - expect(evict).toHaveBeenCalledWith('session-1') - } finally { - vi.useRealTimers() - } - }) - - it('does not start a window for a session nothing released', async () => { - const evict = vi.fn(async () => {}) - const releasing = clock({ evict }) - - releasing.renew('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - - expect(releasing.isArmed('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('reports a failed eviction rather than swallowing it', async () => { - const onError = vi.fn() - const releasing = clock({ - evict: async () => { - throw new Error('child would not stop') - }, - onError - }) - - releasing.arm('session-1') - - await vi.waitFor(() => - expect(onError).toHaveBeenCalledWith({ - sessionId: 'session-1', - error: expect.objectContaining({ message: 'child would not stop' }) - }) - ) - }) -}) - -describe('holds', () => { - it('resumes a session on its first hold and not on a retained one', async () => { - let child = false - const resume = vi.fn(async () => { - child = true - return { ok: true as const } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - await holds.hold('session-1', 'stream-1', { resume: false }) - expect(resume).not.toHaveBeenCalled() - - await holds.hold('session-1', 'chat-1') - expect(resume).toHaveBeenCalledOnce() - - child = true - await holds.hold('session-1', 'chat-2') - expect(resume).toHaveBeenCalledOnce() - holds.dispose() - }) - - it('runs one resume for a writer and a hold that ask in the same gap', async () => { - const gate = Promise.withResolvers() - let child = false - const resume = vi.fn(async () => { - await gate.promise - child = true - return { ok: true as const } - }) - const serialize = keyedSerialize() - const holds = new StructuredAgentSessionHolds({ - resume, - serialize, - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - // A send's ensure-owner step: already inside the session's serialize when it asks. - const writer = serialize('session-1', () => holds.ensureProviderChild('session-1')) - const hold = holds.hold('session-1', 'chat-1') - gate.resolve() - - await expect(writer).resolves.toEqual({ ok: true }) - await hold - // The hold ran after the writer's step and found the child: nothing to resume. - expect(resume).toHaveBeenCalledOnce() - // The surface arrived while the writer's resume ran, so the child it got is held, not idle. - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - holds.dispose() - }) - - it('puts a child a writer resumed with no surface on the idle clock', async () => { - let child = false - const serialize = keyedSerialize() - const holds = new StructuredAgentSessionHolds({ - resume: async () => { - child = true - return { ok: true as const } - }, - serialize, - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 60_000 - }) - - await serialize('session-1', () => holds.ensureProviderChild('session-1')) - - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(true) - holds.dispose() - }) - - it('never arms the clock for a session with nothing to stop', async () => { - const evict = vi.fn(async () => {}) - const holds = new StructuredAgentSessionHolds({ - resume: async () => ({ ok: true as const }), - serialize: keyedSerialize(), - hasProviderChild: () => false, - hasOwedWork: () => false, - evict, - graceMs: 1 - }) - - await holds.hold('session-1', 'chat-1', { resume: false }) - holds.release('session-1', 'chat-1') - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(evict).not.toHaveBeenCalled() - expect(holds.isReleasePending('session-1')).toBe(false) - holds.dispose() - }) - - it('fails a write-capable hold when resume proves no provider child', async () => { - const holds = new StructuredAgentSessionHolds({ - resume: async () => ({ ok: true as const }), - serialize: keyedSerialize(), - hasProviderChild: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow( - 'agent_session_ownership_unknown' - ) - expect(holds.isHeld('session-1')).toBe(false) - holds.dispose() - }) -}) - -describe('the teardown deadline', () => { - it('leaves the child loaded instead of forcing it, and keeps the session indexed', async () => { - const acknowledgeRelease = vi.fn() - const releaseLease = vi.fn(async () => {}) - - await expect( - evictStructuredAgentSession( - { - sessionId: 'session-1', - eventSink: { - unbind: vi.fn(), - drained: vi.fn(async () => {}), - close: vi.fn() - } as never, - adapter: { closeSession: () => new Promise(() => {}) } as never, - acknowledgeRelease, - discardSink: vi.fn(), - releaseLease - }, - withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS, 5) - ) - ).rejects.toMatchObject({ step: 'stop-provider-child' }) - - expect(acknowledgeRelease).not.toHaveBeenCalled() - expect(releaseLease).not.toHaveBeenCalled() - }) - - it('names the step that ran out of time', async () => { - const [step] = withStructuredAgentSessionEvictionDeadline( - [{ name: 'slow-step', run: () => new Promise(() => {}) }], - 5 - ) - - await expect(step?.run({} as never)).rejects.toBeInstanceOf( - StructuredAgentSessionEvictionTimeoutError - ) - }) - - it('does not delay a step that finishes', async () => { - const ran: string[] = [] - const steps = withStructuredAgentSessionEvictionDeadline( - [{ name: 'fast-step', run: () => void ran.push('fast-step') }], - 5_000 - ) - - await steps[0]?.run({} as never) - - expect(ran).toEqual(['fast-step']) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts deleted file mode 100644 index 0acbccb6b55f..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts +++ /dev/null @@ -1,179 +0,0 @@ -// The lifetime of a structured session, tied to the surfaces that want one. -// -// Nothing used to tell the host that a chat WANTED a session, and nothing told it when a chat -// stopped wanting one. Both halves of that gap cost real processes: sessions nobody had opened got -// an app-server at every launch, and sessions the user closed kept theirs until the app quit. -// -// A surface takes a hold when it binds and drops it when it goes away. The first hold on a session -// with no child resumes it — that, and not the shape of a lease on disk, is what makes a provider -// process exist. The last hold leaving starts the idle release clock. Transport close is the BACKSTOP, -// not the mechanism: a client that vanishes mid-flight never sends its release, so the caller -// registers one against the connection and the holder set absorbs the duplicate. -// -// A send to a childless session resumes it too, and so does provider-exit recovery under an open -// surface. All three go through `ensureProviderChild` inside the session's serialize, so they take -// turns: the first to run attaches, and the next finds the child and attaches nothing. Two attaches -// for one session would race against the same released fence, and the loser's stale fence refused -// it — a hold that lost dropped its holder, a send that lost was refused. - -import { - StructuredAgentSessionReleaseClock, - type StructuredAgentSessionReleaseClockDeps -} from './structured-agent-session-release-clock' -import { StructuredAgentSessionHolders } from './structured-agent-session-holders' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' -import type { StructuredAgentSessionAttachOptions } from './structured-agent-session-attach-orchestration' - -export type StructuredAgentSessionHoldsDeps = { - /** Attaches a provider child, for a caller already inside `serialize`. */ - resume: ( - sessionId: string, - attachOptions?: StructuredAgentSessionAttachOptions - ) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - /** Whether evicting this session would actually free anything. */ - hasProviderChild: (sessionId: string) => boolean - hasOwedWork: (sessionId: string) => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void - graceMs?: number -} - -export type StructuredAgentSessionHoldOptions = { - /** False for a hold that only RETAINS — a subscription stream, which must not make a child - * exist just by reading history. */ - resume?: boolean -} - -export class StructuredAgentSessionHolds { - private readonly holders = new StructuredAgentSessionHolders() - private readonly clock: StructuredAgentSessionReleaseClock - private disposed = false - - constructor(private readonly deps: StructuredAgentSessionHoldsDeps) { - const clockDeps: StructuredAgentSessionReleaseClockDeps = { - hasOwedWork: deps.hasOwedWork, - isHeld: (sessionId) => this.holders.isHeld(sessionId), - evict: (sessionId) => this.deps.evict(sessionId), - ...(deps.onError ? { onError: deps.onError } : {}), - ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }) - } - this.clock = new StructuredAgentSessionReleaseClock(clockDeps) - } - - async hold( - sessionId: string, - holderId: string, - options: StructuredAgentSessionHoldOptions = {} - ): Promise { - const alreadyHeld = this.holders.has(sessionId, holderId) - this.holders.add(sessionId, holderId, options.resume !== false) - const incarnation = this.holders.incarnation(sessionId, holderId) - // Unconditional, not only on the first-holder edge: a second surface arriving during the grace - // window must cancel the pending release too. - this.clock.cancel(sessionId) - if (options.resume === false) { - return - } - let resumed: StructuredAgentSessionResumeOutcome - try { - resumed = await this.deps.serialize(sessionId, () => this.ensureProviderChild(sessionId)) - } catch (error) { - this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - throw error - } - if (!resumed.ok) { - this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - // The RPC surface raises a refusal as its code. - throw new Error(resumed.refusal.code) - } - } - - /** Only the holder this call added, at the incarnation it added: a same-ID hold that left and - * came back while this one waited owns the holder now, and its own attempt decides it. */ - private releaseFailedHold( - sessionId: string, - holderId: string, - alreadyHeld: boolean, - incarnation: symbol | undefined - ): void { - if (!alreadyHeld && incarnation !== undefined) { - this.release(sessionId, holderId, incarnation) - } - } - - /** - * Gives the session a provider child if it has none. - * - * For a caller already inside the session's serialize, which is what makes "if it has none" - * exact: a hold and a send that both find the owner gone run this in turn, and the second sees - * the first one's child. Each caller makes at most one attach, and a failed one leaves the - * next caller to make its own. With no surface holding the session afterwards, the child goes - * on the same clock a departed surface would start — including the surface that held it when - * provider-exit recovery began and left while the attach ran. - */ - async ensureProviderChild( - sessionId: string, - attachOptions?: StructuredAgentSessionAttachOptions - ): Promise { - if (this.deps.hasProviderChild(sessionId)) { - return { ok: true } - } - const resumed = await this.deps.resume(sessionId, attachOptions) - if (!resumed.ok) { - return resumed - } - if (!this.deps.hasProviderChild(sessionId)) { - return { - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - message: 'The session attached without a provider child to write to.' - } - } - } - // The last surface can disconnect before acquisition makes a child available to release. - if (!this.disposed && !this.holders.isHeld(sessionId)) { - this.clock.arm(sessionId) - } - return { ok: true } - } - - /** Activity — a journal write, or a start reaching the work it held; only a pending release - * notices, and it restarts its full window. */ - renew(sessionId: string): void { - this.clock.renew(sessionId) - } - - release(sessionId: string, holderId: string, expectedIncarnation?: symbol): void { - if (!this.holders.remove(sessionId, holderId, expectedIncarnation)) { - return - } - if (!this.disposed && this.deps.hasProviderChild(sessionId)) { - this.clock.arm(sessionId) - } - } - - /** Drops the holders of a session that is gone, whoever evicted it. */ - forget(sessionId: string): void { - this.clock.cancel(sessionId) - this.holders.forget(sessionId) - } - - isHeld(sessionId: string): boolean { - return this.holders.isHeld(sessionId) - } - - hasResumeCapableHolder(sessionId: string): boolean { - return this.holders.hasResumeCapableHolder(sessionId) - } - - isReleasePending(sessionId: string): boolean { - return this.clock.isArmed(sessionId) - } - - dispose(): void { - this.disposed = true - this.clock.dispose() - } -} 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 41b83e77eae0..07fece8ceac7 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 @@ -10,7 +10,7 @@ import { type OpenedStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 6b2b869548c9..c2487161f605 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -1,11 +1,13 @@ -// The host's half of a session's lifetime: what a close does, and what a hold is wired to. +// The host's half of a session's lifetime: stopping its agent, and closing its conversation. // -// Lifted out of the host for the same reason attaching was — the host is a coordinator, and the -// sequence that stops a provider child and hands its lease back reads better next to the holder -// bookkeeping that decides when to run it than buried among the twenty other things a session can -// do. +// Two operations, because they end two different things. Stopping the agent ends the provider +// child and hands the lease back; the conversation — its open journal, its status row and its +// readers — stays, and the next send starts a new child. Closing the conversation drops the open +// journal handle, a cache the next read or write reopens. +// +// Both are written for a caller already inside the session's serialize: the queue is not +// reentrant, so every public entry point takes it once and calls these. -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_REJECTED_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' import { @@ -14,7 +16,6 @@ import { type StructuredAgentSessionEvictionContext } from './structured-agent-session-eviction' import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionChildEndCause, @@ -27,8 +28,6 @@ import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' -import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' export type StructuredAgentSessionLifetimeContext = { @@ -36,8 +35,6 @@ export type StructuredAgentSessionLifetimeContext = { runtimeState: StructuredAgentSessionHostRuntimeState sessions: Map now: () => number - /** Drops the session's row from the agent-status store; see `forgetStructuredAgentSession`. */ - forgetStatus: (sessionId: string) => void /** Re-projects the session's status after its agent stopped and the chat stays. */ publishStatus?: (sessionId: string) => void /** Quit-only snapshot taken immediately before the provider child is stopped. */ @@ -67,30 +64,6 @@ export async function abandonQueuedStructuredAgentSessionMessages( .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) } -/** Dropping a session and dropping its status row are ONE operation: the store keeps the row until - * told, so a caller that only deletes strands a live-looking row no reader can ever decay. */ -export async function forgetStructuredAgentSession( - context: Pick & { - deps: ConversationCloseDeps - }, - sessionId: string -): Promise { - const session = context.sessions.get(sessionId) - if (session) { - await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) - } - await session?.journal.close() - context.sessions.delete(sessionId) - context.forgetStatus(sessionId) -} - -function hasProviderChild( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): boolean { - return (context.sessions.get(sessionId)?.child ?? null) !== null -} - /** The wind-down this host owes for the session's child. A live child always owes one, whatever a * previous childless eviction recorded: a remembered tombstone must never outrank the child in * front of it. */ @@ -106,7 +79,7 @@ function owedProviderChildWindDown( * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next * stop is a real retry. `ending` is how the child's end is told: a user's Stop, the host stopping it - * for a cause (with its text), or an eviction whose close forgets the conversation next. + * for a cause (with its text), or an eviction the conversation's close follows. */ export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, @@ -170,7 +143,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( } }) if (!settled) { - // Without the cause the quit log names the step and nothing else. + // Without the cause the log names the step and nothing else. throw new Error('dead generation work settlement failed', { cause: settlementError }) } }, @@ -185,11 +158,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( }) } session.owesProviderChildWindDown = undefined - if (ending.cause === 'evict') { - context.forgetStatus(sessionId) - return - } - // The conversation stays: its readers keep their own fence, and only the status moves. + // Whatever ended the child, the row belongs to the conversation: it shows not-running, and + // only the conversation's close forgets it. context.publishStatus?.(sessionId) } } @@ -199,17 +169,39 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } -/** Ends the conversation's resources, not the conversation: its child stops, and then its handle - * closes and it leaves the map. A stop that fails throws first, leaving it indexed for a retry. */ -export async function evictHeldStructuredAgentSession( - context: StructuredAgentSessionLifetimeContext, +/** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing + * queued or waiting on the provider. */ +export function structuredAgentSessionConversationClosable( + session: StructuredAgentSessionHostSession +): boolean { + return ( + owedProviderChildWindDown(session) === undefined && + !session.journal.submissions().some(isQueuedAgentJournalSubmission) && + session.journal.pendingSubmissions().length === 0 + ) +} + +/** + * Drops the conversation's open handle. The entry leaves the map BEFORE the handle closes, so a + * lock-free reader sees an open handle or none — never one that is closing — and one arriving + * after the delete waits behind this step and reopens. Answers false, closing nothing, when the + * handle is still more than a cache. + */ +export async function closeStructuredAgentSessionConversationUnderSerialize( + context: Pick & { + /** The status row outlives the handle; see `StructuredAgentSessionClientDelivery`. */ + closeStatus: (sessionId: string) => void + }, sessionId: string -): Promise { - if (!context.sessions.has(sessionId)) { - return +): Promise { + const session = context.sessions.get(sessionId) + if (!session || !structuredAgentSessionConversationClosable(session)) { + return false } - await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) - await forgetStructuredAgentSession(context, sessionId) + context.sessions.delete(sessionId) + context.closeStatus(sessionId) + await session.journal.close() + return true } /** Stops every provider child owned by this host while keeping failed evictions reachable. A @@ -224,7 +216,7 @@ export async function evictOwnedStructuredAgentSessions( const ownedSessionIds = [...context.sessions] .filter(([, session]) => owedProviderChildWindDown(session) !== undefined) .map(([sessionId]) => sessionId) - // Retained up front and cleared only once an eviction settles: the quit phase is bounded, and a + // Retained up front and cleared only once a stop settles: the quit phase is bounded, and a // timeout leaves these still running. Closing their journals underneath them is the one outcome // the retain set exists to prevent. for (const sessionId of ownedSessionIds) { @@ -235,7 +227,7 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - evictHeldStructuredAgentSession(context, sessionId) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId) ) retainOnFailure.delete(sessionId) } catch (error) { @@ -247,46 +239,3 @@ export async function evictOwnedStructuredAgentSessions( throw new AggregateError(failures, 'structured agent-session child eviction failed') } } - -/** The holds resume through the host's own attach, inside the session's serialize: a hold's - * resume and a send's ensure-owner step are the same serialized attach with a different asker. */ -export function createStructuredAgentSessionHolds( - attachContext: () => StructuredAgentSessionAttachContext, - close: (sessionId: string) => Promise, - deliveryActive: (sessionId: string) => boolean -): StructuredAgentSessionHolds { - const context = attachContext() - return new StructuredAgentSessionHolds({ - resume: (sessionId, attachOptions) => - resumeHeldStructuredAgentSession({ - sessionId, - context: attachContext(), - callerKey: attachOptions?.admitRecoveryTicket - ? 'trusted-local:provider-exit-recovery' - : 'trusted-local:surface-hold', - ...(attachOptions ? { attachOptions } : {}) - }), - // Tracked from enqueue: a quit drains a queued resume before it evicts, so no child is - // spawned behind the eviction and orphaned. - serialize: (sessionId, task) => { - const current = attachContext() - return current.tasks.trackAttach(current.serialize(sessionId, task)) - }, - evict: close, - hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), - // A message accepted and not yet handed over is owed to this child, and so is one pending while - // the child still starts. Any other pending send may wait on an echo that never comes, so - // eviction retires it. - hasOwedWork: (sessionId) => { - const session = context.sessions.get(sessionId) - return session - ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null || - deliveryActive(sessionId) || - session.journal.submissions().some(isQueuedAgentJournalSubmission) || - (session.child?.phase === 'starting' && session.journal.pendingSubmissions().length > 0) - : false - }, - onError: (error) => context.deps.onEventSinkError?.(error), - ...(context.deps.releaseGraceMs === undefined ? {} : { graceMs: context.deps.releaseGraceMs }) - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 5f2e7bd74527..6d64b9089def 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -1,10 +1,11 @@ -// Everything a client can ask an ATTACHED session to do: send a turn, cancel one, answer a prompt, -// change an option, read the options back. +// Everything a client can ask a session to do: send a turn, cancel one, answer a prompt, change an +// option, read the options back. // // They share one shape — admit the envelope against the lease, run a plan, publish the journal — so -// they share one path here rather than five copies in the host. The host keeps attach, holds and -// teardown. A send and a Stop are conversation writes: they open the conversation and are admitted -// without the writer lease; the session's delivery loop starts the provider child a send needs. +// they share one path here rather than five copies in the host. The host keeps attach and teardown. +// Each opens the conversation first. A send, a Stop and an option pick are conversation writes, +// admitted without the writer lease; the delivery loop starts the provider child a send needs, and +// an operation only the provider can perform starts it before admission. import type { AgentJournalItemIdentity, @@ -15,7 +16,6 @@ import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, AgentSessionOptionResult, - AgentSessionOptionsResult, AgentSessionPromptResult, AgentSessionSendResult, AgentSessionThreadGoalChange, @@ -26,10 +26,13 @@ import { threadGoalPlan } from './structured-agent-session-thread-goal' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, - type AgentSessionMutationRequest + type AgentSessionMutationRequest, + type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' import { - openConversationForWrite, + openForWrite, + openWithAgent, + sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { @@ -44,16 +47,23 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + readStructuredAgentSessionOptions, + recordStructuredAgentSessionOptionIntent +} from './structured-agent-session-options-read' export type StructuredAgentSessionMutationContext = { deps: StructuredAgentSessionHostDeps sessions: Map publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void flushStreamedEvents: (sessionId: string) => Promise - requireSession: (sessionId: string) => StructuredAgentSessionHostSession + /** The host's accessor, for a caller outside the session's serialize. */ + conversation: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise + /** Gives the session a provider child; inside the caller's serialize. */ + ensureAgent: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ @@ -114,7 +124,7 @@ export function sendStructuredAgentSessionTurn( return accepted } }, - () => openConversationForWrite(context.openConversation, params.envelope) + sendPreparation(context, params.envelope) ) } @@ -141,7 +151,13 @@ export function cancelStructuredAgentSessionTurn( : context const plan = cancelPlan(params) if (params.scope || params.prompt) { - return mutate(cancellationContext, caller, params.envelope, plan) + return mutate( + cancellationContext, + caller, + params.envelope, + plan, + openForWrite(context, params.envelope) + ) } return mutate( cancellationContext, @@ -166,7 +182,7 @@ export function cancelStructuredAgentSessionTurn( : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } } }, - () => openConversationForWrite(context.openConversation, params.envelope) + openForWrite(context, params.envelope) ) } @@ -181,7 +197,13 @@ export function respondToStructuredAgentSessionPrompt( optionId: string } ): Promise> { - return mutate(context, caller, params.envelope, promptPlan(params)) + return mutate( + context, + caller, + params.envelope, + promptPlan(params), + openForWrite(context, params.envelope) + ) } export async function setStructuredAgentSessionOption( @@ -191,7 +213,26 @@ export async function setStructuredAgentSessionOption( ): Promise> { // Outside the queue: a pick made while the provider starts then queues behind what its start persists. await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) - return mutate(context, caller, params.envelope, setOptionPlan(params)) + const plan = setOptionPlan(params) + const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child + return mutate( + context, + caller, + params.envelope, + { + ...plan, + // Read as the call is admitted: with no child running, the pick is a conversation write — + // intent the next start replays. A running child's pick is still its owner's to make. + get conversationWrite() { + return atRest() ? (true as const) : undefined + }, + run: (ctx) => + atRest() + ? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params) + : plan.run(ctx) + }, + openForWrite(context, params.envelope) + ) } export function changeStructuredAgentSessionThreadGoal( @@ -199,43 +240,13 @@ export function changeStructuredAgentSessionThreadGoal( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutate(context, caller, params.envelope, threadGoalPlan(params)) -} - -export function readStructuredAgentSessionOptions( - context: StructuredAgentSessionMutationContext, - sessionId: string -): Promise { - return context.serialize(sessionId, async () => { - const session = context.requireSession(sessionId) - if (!context.deps.adapter.readOptions) { - throw new Error('structured_agent_session_options_unsupported') - } - const options = await context.deps.adapter.readOptions({ - sessionId, - fence: - session.child?.fence ?? - structuredAgentSessionConversationFence(context.deps.store, sessionId) - }) - return { - ...options, - rewind: - context.deps.store.getRecord(sessionId)?.rewind?.phase === 'prepared' || - context.deps.store.getRecord(sessionId)?.rewind?.phase === 'provider-succeeded' - ? { supported: false, reason: 'outcome-unknown' } - : (context.deps.adapter.rewindSupport?.(sessionId) ?? { - supported: false, - reason: 'unsupported' - }), - conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear'], - ...(context.deps.adapter.supportsThreadGoal?.(sessionId) - ? { threadGoal: { current: session.journal.threadGoal() } } - : {}), - ...(context.deps.adapter.recordsContextUsage?.(sessionId) - ? { contextUsage: { current: session.journal.contextUsage() } } - : {}) - } - }) + return mutate( + context, + caller, + params.envelope, + threadGoalPlan(params), + openWithAgent(context, params.envelope) + ) } /** Settle provider-proven delivery independently of an in-flight client mutation. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index d04e17d2d26c..5fd0440a1fe1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -5,7 +5,7 @@ describe('structured agent-session host teardown', () => { it('names every phase, so the quit-path order is pinned rather than incidental', () => { const noop = async (): Promise => undefined const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: noop }, + idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, @@ -14,7 +14,7 @@ describe('structured agent-session host teardown', () => { }) expect(phases.map((phase) => phase.name)).toEqual([ 'begin-resume-markers', - 'dispose-holds', + 'dispose-idle-sweep', 'stop-lease-renewal', 'drain-attaches', 'evict-owned-sessions', @@ -30,7 +30,7 @@ describe('structured agent-session host teardown', () => { const cleaned = vi.fn(async () => {}) const flush = vi.fn(async () => cleaned()) const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: cleaned }, + idleSweep: { dispose: cleaned }, runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 2f1b0f3c287a..33149381c81d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -47,7 +47,7 @@ async function withPhaseTimeout(run: () => Promise, timeoutMs: number): Pr /** The quit-path phase order, which is load-bearing rather than incidental. */ export function structuredAgentSessionHostTeardownPhases(collaborators: { - holds: { dispose: () => Promise | void } + idleSweep: { dispose: () => Promise | void } runtimeState: { stopLeaseRenewal: () => void flushAllEventSinks: () => Promise @@ -69,7 +69,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { } } }, - { name: 'dispose-holds', run: () => collaborators.holds.dispose() }, + { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { @@ -141,7 +141,7 @@ export async function tearDownStructuredAgentSessionHost(input: { export async function flushStructuredAgentSessionHost( context: StructuredAgentSessionLifetimeContext & - Pick[0], 'holds' | 'tasks'> & { + Pick[0], 'idleSweep' | 'tasks'> & { restartResume: StructuredAgentSessionRestartResume serialize: (sessionId: string, task: () => Promise) => Promise trigger: AgentSessionResumeTrigger diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index e0ac9c00d88a..1e0d77b0d524 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -107,7 +107,7 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis }) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null if (!appended) { throw new Error('provider approval was not written to the journal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 5a7161949c8a..03dade444432 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -18,7 +18,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } /** What the host believes about a session it just made addressable again. The workspace and agent * come from the record, so a caller publishes the host's view rather than a client's assertion. * `readable` is false when the journal could not be opened — the tab is still worth publishing, - * because attach recovers what read restore cannot. */ + * because the chat shows that failure and its Retry. */ export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string @@ -102,8 +102,10 @@ export type StructuredAgentSessionHostDeps = { provider: AgentSessionRecord['provider'] ) => Promise | undefined> | Record | undefined now?: () => number - /** How long a session outlives its last surface. Tests drive this; production takes the default. */ - releaseGraceMs?: number + /** The idle sweep's period and window. Tests drive these; production takes the defaults. */ + idleSweep?: { intervalMs?: number; idleMs?: number } + /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ + hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index f85919267660..e65e1bd33fc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -208,7 +208,7 @@ describe('cancel', () => { turnId: 'turn-1' }) expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items[0]?.body).toMatchObject({ kind: 'status', text: 'Cancellation requested.' @@ -332,7 +332,7 @@ describe('cancel', () => { refusal: { code: 'agent_session_operation_unknown' } }) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + expect(await host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ ok: true, page: { items: [ @@ -395,7 +395,7 @@ describe('respondToPrompt', () => { ...fields }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const answered = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null expect(answered).toMatchObject({ revision: 2, @@ -490,7 +490,7 @@ describe('respondToPrompt', () => { ...fields }) expect(result.ok).toBe(true) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const statusId = agentJournalItemKey({ provider: 'orca', clientMessageId: `${prompt.itemId}#delivery` @@ -520,7 +520,7 @@ describe('setOption', () => { }) expect(setOption).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) @@ -601,13 +601,13 @@ describe('restart', () => { expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items).not.toHaveLength(0) expect(acquire).not.toHaveBeenCalled() expect(listRecords).toHaveBeenCalledTimes(restoreReads) }) - it('clears a stale conflicted recovery at restart, and reacquires the native owner when a surface holds it', async () => { + it('clears a stale conflicted recovery at restart, and reacquires the native owner on the next start', async () => { await attach() await store.transitionHandoff(SESSION, (record) => ({ ...record, @@ -622,9 +622,10 @@ describe('restart', () => { acquire.mockClear() await host.restoreReadableSessions() - // The recovery stage clears on evidence at startup; the child comes back only once a surface - // holds the session (see structured-agent-session-surface-lifetime.test.ts). - await host.hold(SESSION, 'surface-1') + // The recovery stage clears on evidence at startup; the child comes back only once work + // starts it — here the explicit attach a send's delivery would make. + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(await host.attach(CALLER, ensureParams(fence))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -656,11 +657,14 @@ describe('restart', () => { return settled(input) }) - const hold = host.hold(SESSION, 'surface-1') + const start = host.attach( + CALLER, + ensureParams(store.getRecord(SESSION)?.lease.runtimeFence ?? 0) + ) await started.promise const status = host.handoffStatus(SESSION) release.resolve() - await hold + await start await expect(status).resolves.toMatchObject({ owner: 'native', stage: null }) }) @@ -696,7 +700,7 @@ describe('subscribe', () => { it('opens with a snapshot and then streams cursor-qualified batches', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - const dispose = host.subscribe({ + const dispose = await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) @@ -726,7 +730,7 @@ describe('subscribe', () => { } const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-2', sessionId: SESSION, emit: (event) => events.push(event), @@ -747,14 +751,14 @@ describe('subscribe', () => { it('drops a failed transport without aborting the mutation or other subscribers', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'dead-sub', sessionId: SESSION, emit: () => { throw new Error('socket closed') } }) - host.subscribe({ + await host.subscribe({ id: 'live-sub', sessionId: SESSION, emit: (event) => events.push(event) @@ -784,7 +788,7 @@ describe('subscribe', () => { it('resets a subscriber whose epoch is gone', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-3', sessionId: SESSION, emit: (event) => events.push(event), @@ -796,7 +800,7 @@ describe('subscribe', () => { it('publishes the replacement fence when the owner generation changes', async () => { const record = await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-4', sessionId: SESSION, emit: (event) => events.push(event) 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 c35a0ed1cbb7..4f2547f027ff 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 @@ -2,31 +2,34 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rew import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. -// Mutations share one durable admission path and serialize per session. +// Mutations share one durable admission path and serialize per session. A conversation is reached +// only through `conversation`, which opens it at rest; an agent is started only by work that needs +// it, and the idle sweep is the one thing that puts it to rest. import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { createStructuredAgentSessionHostRestore } from './structured-agent-session-reveal' +import { + createStructuredAgentSessionHostRestore, + revealStructuredAgentSession +} from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { - createStructuredAgentSessionHolds, - evictHeldStructuredAgentSession, - stopStructuredAgentSessionAgentUnderSerialize, - type StructuredAgentSessionLifetimeContext -} from './structured-agent-session-host-lifetime' -import type { - StructuredAgentSessionHolds, - StructuredAgentSessionHoldOptions -} from './structured-agent-session-holds' + ensureStructuredAgentSessionAgent, + ensureStructuredAgentSessionAgentForOperation +} from './structured-agent-session-agent-start' +import { + createStructuredAgentSessionConversationLifetime, + type StructuredAgentSessionConversationLifetime +} from './structured-agent-session-conversation-lifetime' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import * as sessionTabs from './structured-agent-session-host-tabs' import { @@ -39,7 +42,6 @@ import { flushStructuredAgentSessionHost } from './structured-agent-session-host import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' @@ -64,13 +66,15 @@ export class StructuredAgentSessionHost { ) private readonly sessions = new StructuredAgentSessionConversations({ deliver: (sessionId, journal) => this.subscribers.publish(sessionId, journal), - onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }) + onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), + now: () => this.now() }) + // Every journal publish is activity: the one renewal the idle sweep reads. private readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, () => this.now(), () => this.deps, - (sessionId) => this.holds.renew(sessionId) + (sessionId) => this.sessions.touch(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -79,7 +83,7 @@ export class StructuredAgentSessionHost { sessionId: string ) => Promise private readonly restore: ReturnType - private readonly holds: StructuredAgentSessionHolds + private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: ReturnType< typeof createStructuredAgentSessionConversationDelivery > @@ -93,7 +97,7 @@ export class StructuredAgentSessionHost { deps, this.sessions, this.subscribers, - (sessionId) => this.requireSession(sessionId), + (sessionId) => this.lifetime.conversation(sessionId), this.clientDelivery.publishStatus ) this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => @@ -111,7 +115,8 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. trackStart: (start) => this.tasks.trackAttach(start), - ensureProviderChild: (sessionId) => this.holds.ensureProviderChild(sessionId), + ensureProviderChild: (sessionId) => + ensureStructuredAgentSessionAgent(this.attachContext(), sessionId), reset: (sessionId, journal, reset) => this.subscribers.reset( sessionId, @@ -121,11 +126,6 @@ export class StructuredAgentSessionHost { ), publishRestored: this.clientDelivery.publishRestored }) - this.holds = createStructuredAgentSessionHolds( - () => this.attachContext(), - (sessionId) => this.close(sessionId), - (sessionId) => this.conversationDelivery.loop.isRunning(sessionId) - ) this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { reconcile: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), @@ -147,12 +147,8 @@ export class StructuredAgentSessionHost { structuredAgentSessionConversationFence(deps.store, sessionId) ), publishStatus: this.clientDelivery.publishStatusAndSettlement, - hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), - restartReleaseGrace: (sessionId) => this.holds.renew(sessionId), - // Tracked: a quit drains a queued restart before it evicts, so no child outlives it. serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), - ensureProviderChild: (id, options) => this.holds.ensureProviderChild(id, options), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) this.restartResume = createStructuredAgentSessionRestartResume( @@ -160,24 +156,21 @@ export class StructuredAgentSessionHost { this.sessions, structuredAgentSessionRestartResumeSurfaces(this, this.now) ) + this.lifetime = createStructuredAgentSessionConversationLifetime({ + context: () => this.lifetimeContext(), + sessions: this.sessions, + serialize: (sessionId, task) => this.serialize(sessionId, task), + open: (sessionId) => this.conversationDelivery.open(sessionId), + deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options) + }) this.runtimeState.startLeaseRenewal() + this.lifetime.idleSweep.start() } private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) - isHeld = (sessionId: string): boolean => this.holds.isHeld(sessionId) - - /** A surface bound to this session and wants it live. The FIRST hold on a session with no - * provider child is what resumes one; a retained hold (a subscription) only keeps it. */ - hold = ( - sessionId: string, - holderId: string, - options?: StructuredAgentSessionHoldOptions - ): Promise => this.holds.hold(sessionId, holderId, options) - - /** That surface is gone. The child outlives it by the idle window, and by any running turn. */ - release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId) handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) @@ -188,7 +181,6 @@ export class StructuredAgentSessionHost { runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), - forgetStatus: this.clientDelivery.forgetStatus, publishStatus: this.clientDelivery.publishStatus } } @@ -205,18 +197,8 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open } } - /** Releases a session's resources without ending the conversation: the record and journal stay - * on disk, so the same session can be attached again. */ - close(sessionId: string): Promise { - return this.serialize(sessionId, () => this.closeUnderSerialize(sessionId)) - } - - private async closeUnderSerialize(sessionId: string): Promise { - await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) - this.clientDelivery.closeSession(sessionId) - // The holders now look at a session that is gone; a failed eviction throws above, keeping them. - this.holds.forget(sessionId) - } + /** Releases a session's resources without ending the conversation; see the lifetime's close. */ + close = (sessionId: string): Promise => this.lifetime.close(sessionId) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -224,8 +206,13 @@ export class StructuredAgentSessionHost { listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() - setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => - sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible) + setSessionTabVisibility = async (sessionId: string, visible: boolean): Promise => { + await sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible) + // The tab edge of the row's lifetime; the handle close is the other. + if (!visible && !this.sessions.get(sessionId)?.child) { + this.clientDelivery.forgetStatus(sessionId) + } + } reconcileRestartLeases = async (): Promise => { const refusal = await this.reconcileLeases('startup') @@ -239,7 +226,7 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - this.restore.revealSession(sessionId) + revealStructuredAgentSession(this.deps, sessionId, (id) => this.lifetime.conversation(id)) private serialize = this.tasks.serialize.bind(this.tasks) @@ -259,7 +246,7 @@ export class StructuredAgentSessionHost { this.conversationDelivery.loop.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), - holds: this.holds, + idleSweep: this.lifetime, tasks: this.tasks, restartResume: this.restartResume, serialize: this.serialize, @@ -273,14 +260,13 @@ export class StructuredAgentSessionHost { sessions: this.sessions, publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, - requireSession: (sessionId) => this.requireSession(sessionId), + conversation: this.lifetime.conversation, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, + ensureAgent: (sessionId) => + ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: (sessionId) => - stopStructuredAgentSessionAgentUnderSerialize(this.lifetimeContext(), sessionId, { - cause: 'user-stop' - }), + stopAgent: this.lifetime.stopAgent, now: () => this.now() } } @@ -308,7 +294,7 @@ export class StructuredAgentSessionHost { }) async handoffStatus(sessionId: string): Promise { - this.requireSession(sessionId) + await this.lifetime.conversation(sessionId) // Queued behind an in-flight attach, so a starting chat answers with its settled owner. return this.serialize(sessionId, async () => { const record = this.deps.store.getRecord(sessionId) @@ -324,10 +310,10 @@ export class StructuredAgentSessionHost { /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — rows are * revised or tombstoned in place, so an item's ABSENCE from a bounded page proves nothing. */ - journalSnapshot = (sessionId: string): AgentJournalSnapshot => - this.requireSession(sessionId).journal.snapshot() + journalSnapshot = async (sessionId: string): Promise => + (await this.lifetime.conversation(sessionId)).journal.snapshot() - subscribe = (input: AgentSessionSubscribeInput): (() => void) => + subscribe = (input: AgentSessionSubscribeInput): Promise<() => void> => this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => @@ -349,12 +335,4 @@ export class StructuredAgentSessionHost { subscribeTurnCompletions = ( subscriber: StructuredAgentSessionTurnCompletionSubscriber ): (() => void) => this.clientDelivery.subscribeTurnCompletions(subscriber) - - private requireSession(sessionId: string): StructuredAgentSessionHostSession { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) - } - return session - } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts new file mode 100644 index 000000000000..5c63049916df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -0,0 +1,255 @@ +// The idle sweep on a real host: what puts an agent to rest, what keeps it running, and what a +// reader and the session lists see when it does. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + readerSaw, + IDLE_MS, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD as THREAD, + restTestSend, + sweepTicks, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig() +}) + +afterEach(async () => { + await rig.dispose() +}) + +function providerEvents() { + const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no provider child was started') + } + return events +} + +function lastStatus(): AgentSessionStatusSummary | undefined { + return rig.statusEvents + .flatMap((event) => (event.type === 'status' ? [event.session] : [])) + .findLast((summary) => summary.sessionId === SESSION) +} + +function fence(): number { + return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 +} + +describe('the idle sweep', () => { + it('stops an idle agent and keeps the conversation, its status row and its reader (P2-07)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) + await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) + expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) + // Listed, idle, and no longer running on this host — never dropped from any list. + expect(lastStatus()).toMatchObject({ status: 'idle' }) + expect(lastStatus()?.hostExecutionOwned).toBeUndefined() + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + + // Everything a chat at rest answers still answers, and the next send starts a new agent. + await expect(rig.host.readOptions(SESSION)).resolves.toMatchObject({ current: {} }) + const sent = await rig.host.send(CALLER, restTestSend('carry on', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('carry on')) + }) + + it('never stops an agent a message is queued for, and hands the message over (P2-08)', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockClear() + // The loop takes the message and waits on the child's start, outside the lock. + const started = Promise.withResolvers() + const awaitStarted = vi.fn(() => started.promise) + Object.assign(rig.host.deps.adapter, { awaitStarted }) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + const sent = await rig.host.send(CALLER, restTestSend('queued one', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(awaitStarted).toHaveBeenCalled()) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + started.resolve() + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + const settled = (await rig.host.journalSnapshot(SESSION)).submissions.at(-1) + expect(settled?.dispatchState).toBe('accepted') + const seen = readerSaw(reader.events).submissions + expect(seen.some((row) => row.dispatchState === 'accepted')).toBe(true) + expect( + seen.some((row) => row.dispatchState === 'rejected' || row.dispatchState === 'unknown') + ).toBe(false) + }) + + it('never stops an agent whose background work still runs (P2-09)', async () => { + await foundRestTestChat(rig) + // A Codex subagent thread is one of these tasks; the tracker projects both kinds alike. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background roster fixture; the sweep reads only whether live tasks exist. + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [ + { + taskId: 'subagent-1', + kind: 'subagent', + status: 'running', + title: 'reviewer', + startedAt: rig.clock.now + } + ] + } as never) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.adapter.backgroundTaskState.mockReturnValue(undefined) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { + await foundRestTestChat(rig) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + }) + + it('stops an agent whose chat is still open on screen (P2-12)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + }) + + it('counts the idle window from the last activity (P2-13)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS - 60_000 + // Activity at 29 minutes: a provider row reaching the journal. + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 60 }, + { kind: 'status', text: 'still thinking' } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += 60_001 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { + await rig.dispose() + let open = true + const hasOpenDispatch = vi.fn(() => open) + rig = await createRestTestRig({ hasOpenDispatch }) + await foundRestTestChat(rig) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) + open = false + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { + await foundRestTestChat(rig) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'subagent-turn', ordinal: 70 }, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + }) +}) + +describe('the idle sweep with no child running (P2-22 ii)', () => { + it('closes a handle whose only leftover is a prompt nobody can answer', async () => { + const journal = { + submissions: () => [], + pendingSubmissions: () => [], + snapshot: () => ({ + items: [ + { + itemId: 'prompt', + revision: 1, + sequence: 1, + observedAt: 0, + body: { + kind: 'approval', + title: 'Run?', + detail: null, + options: [], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + }) + } + const sessions = Object.assign( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. + new Map([[SESSION, { journal, child: null } as never]]), + { lastActivityAt: () => 0 } + ) + const stopAgent = vi.fn(async () => undefined) + const closeConversation = vi.fn(async () => true) + const sweep = new StructuredAgentSessionIdleSweep({ + sessions, + serialize: (_id, task) => task(), + now: () => IDLE_MS + 1, + isDisposed: () => false, + deliveryActive: () => false, + backgroundTaskState: () => undefined, + hasOpenDispatch: () => false, + stopAgent, + stopStartingAgent: stopAgent, + closeConversation, + onError: (_id, error) => { + throw error + } + }) + await sweep.tick() + expect(stopAgent).not.toHaveBeenCalled() + expect(closeConversation).toHaveBeenCalledWith(SESSION) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts new file mode 100644 index 000000000000..9b263856b079 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -0,0 +1,146 @@ +// The one thing that puts an idle agent to rest. +// +// Nothing a viewer does keeps an agent alive or starts one: a chat on screen and a chat in a +// background tab are the same to this sweep. Every few minutes it looks at each open conversation +// and stops the provider child of one that has been quiet for the idle window and owes no work, +// then drops the open journal handle of one that is only a cache. The conversation itself — its +// record, tab, status row and readers — is untouched, and the next send starts a new child. +// +// Owed work is derived on every tick, never stored, so there is nothing to disagree with it. + +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 +export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 + +export type StructuredAgentSessionIdleSweepDeps = { + sessions: ReadonlyMap & { + lastActivityAt: (sessionId: string) => number | undefined + } + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number + isDisposed: () => boolean + deliveryActive: (sessionId: string) => boolean + backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** An orchestration dispatch that still owns this session's worker; derived from its database. */ + hasOpenDispatch: (sessionId: string) => boolean + /** Each of these runs inside the session's serialize and never takes it again. */ + stopAgent: (sessionId: string) => Promise + stopStartingAgent: (sessionId: string) => Promise + closeConversation: (sessionId: string) => Promise + onError: (sessionId: string, error: unknown) => void + intervalMs?: number + idleMs?: number +} + +/** A prompt the user has not answered. A subagent can raise one the lead turn cannot see. */ +export function hasPendingStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): boolean { + return items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} + +export class StructuredAgentSessionIdleSweep { + private timer: ReturnType | null = null + private running = false + + constructor(private readonly deps: StructuredAgentSessionIdleSweepDeps) {} + + start(): void { + this.timer = setInterval( + () => void this.tick(), + this.deps.intervalMs ?? STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS + ) + // An idle sweep must never be the reason a process stays alive at quit. + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** One pass over every open conversation. Sessions run concurrently, so one waiting behind a + * locked start does not hold up the rest; a pass still running skips the next. */ + async tick(): Promise { + if (this.running || this.deps.isDisposed()) { + return + } + this.running = true + try { + await Promise.allSettled( + [...this.deps.sessions.keys()].map((sessionId) => + this.deps + .serialize(sessionId, () => this.tickUnderSerialize(sessionId)) + .catch((error: unknown) => this.deps.onError(sessionId, error)) + ) + ) + } finally { + this.running = false + } + } + + /** Re-derives everything inside the session's lock, so an accept already queued ahead is seen. */ + private async tickUnderSerialize(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || this.deps.isDisposed()) { + return + } + // A stop that failed after the child was proven gone: finish it now, before the idle test, so + // the rows its settlement wrote cannot push the retry out. A message accepted since goes first. + if ( + session.owesProviderChildWindDown !== undefined && + !session.child && + !this.queuedOrDelivering(sessionId, session) + ) { + await this.deps.stopAgent(sessionId) + return + } + const lastActivityAt = this.deps.sessions.lastActivityAt(sessionId) ?? this.deps.now() + if (this.deps.now() - lastActivityAt < (this.deps.idleMs ?? STRUCTURED_AGENT_SESSION_IDLE_MS)) { + return + } + if (session.child) { + // A start that has been quiet this long is not coming: the host stops it, with its reason. + if (session.child.phase === 'starting') { + await this.deps.stopStartingAgent(sessionId) + return + } + if (this.owesWork(sessionId, session)) { + return + } + await this.deps.stopAgent(sessionId) + } + await this.deps.closeConversation(sessionId) + } + + private queuedOrDelivering(sessionId: string, session: StructuredAgentSessionHostSession) { + return ( + this.deps.deliveryActive(sessionId) || + session.journal.submissions().some(isQueuedAgentJournalSubmission) + ) + } + + /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the + * handle, and a leftover prompt or turn row is only history. */ + private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { + const items = session.journal.snapshot().items + return ( + activeStructuredAgentSessionTurnId(items) !== null || + this.queuedOrDelivering(sessionId, session) || + (this.deps.backgroundTaskState(sessionId)?.tasks?.length ?? 0) > 0 || + this.deps.hasOpenDispatch(sessionId) || + hasPendingStructuredAgentSessionPrompt(items) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index 942c9ef61521..8dabd942705e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -15,7 +15,7 @@ import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' -import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' +import { closeStructuredAgentSessionConversationUnderSerialize } from './structured-agent-session-host-lifetime' import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -73,10 +73,6 @@ function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSe } } -function forgetContext(sessions: Map) { - return { deps: { store: { getRecord: () => null } }, sessions, forgetStatus: () => undefined } -} - beforeEach(async () => { legacyImport.throws = false root = await mkdtemp(join(tmpdir(), 'orca-wire-handles-')) @@ -119,35 +115,42 @@ describe('site 6: recovery rehydration', () => { }) describe('sites 9 and 10: closing a conversation handle', () => { - it('awaits the journal close before dropping the map entry', async () => { + it('drops the map entry before the close, and releases the handle', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir }) const sessions = new Map([[SESSION, hostSession(journal)]]) const order: string[] = [] const close = journal.close.bind(journal) journal.close = async () => { - order.push('close-started') + // A lock-free reader arriving now must find no entry, never a closing handle. + order.push(sessions.has(SESSION) ? 'close-while-indexed' : 'close-after-delete') await close() - order.push(sessions.has(SESSION) ? 'closed' : 'dropped-before-close') + order.push('closed') } - await forgetStructuredAgentSession(forgetContext(sessions), SESSION) + await expect( + closeStructuredAgentSessionConversationUnderSerialize( + { sessions, closeStatus: () => order.push('status') }, + SESSION + ) + ).resolves.toBe(true) - expect(order).toEqual(['close-started', 'closed']) + expect(order).toEqual(['status', 'close-after-delete', 'closed']) expect(sessions.size).toBe(0) await expectNothingHoldsTheDirectory(journalDir) }) - it('keeps the session indexed when the close rejects', async () => { + it('surfaces a rejected close to its caller', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir }) const sessions = new Map([[SESSION, hostSession(journal)]]) const close = journal.close.bind(journal) journal.close = () => Promise.reject(new Error('close rejected')) - await expect(forgetStructuredAgentSession(forgetContext(sessions), SESSION)).rejects.toThrow( - 'close rejected' - ) - // Still indexed, so the next close is a real retry. - expect(sessions.has(SESSION)).toBe(true) + await expect( + closeStructuredAgentSessionConversationUnderSerialize( + { sessions, closeStatus: () => undefined }, + SESSION + ) + ).rejects.toThrow('close rejected') journal.close = close }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 6bda0b661898..d2bfa1101482 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -60,8 +60,8 @@ function sendParams(text: string): { } } -function submissions(): unknown { - const state = host.history({ sessionId: SESSION, direction: 'tail' }) +async function submissions(): Promise { + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) return state.ok ? state.page.submissions : null } @@ -73,7 +73,7 @@ function journal(): AgentSessionJournal { /** A send is accepted first; this waits for the delivery loop to hand it to the provider. */ async function handedOver(clientMessageId: string): Promise { - await vi.waitFor(() => { + await vi.waitFor(async () => { expect( journal() .submissions() @@ -138,14 +138,14 @@ describe('settling a send the provider proves it received after the ack window', }) ) const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'late-receipt', sessionId: SESSION, emit: (event) => events.push(event) }) const params = sendParams('echo before send completes') const pending = host.send(CALLER, params) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await vi.waitFor(async () => expect(dispatch).toHaveBeenCalledTimes(1)) try { await host.settleLateDispatch({ sessionId: SESSION, @@ -166,8 +166,8 @@ describe('settling a send the provider proves it received after the ack window', } await expect(pending).resolves.toMatchObject({ ok: true }) // The late `unknown` from the handover does not reopen the proven acceptance. - await vi.waitFor(() => - expect(submissions()).toMatchObject([ + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) ) @@ -183,7 +183,9 @@ describe('settling a send the provider proves it received after the ack window', const params = sendParams('received just before shutdown') await host.send(CALLER, params) await handedOver(params.envelope.clientOperationId) - await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'unknown' }])) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'unknown' }]) + ) let settlement: Promise | undefined closeSession.mockImplementationOnce(async () => { settlement = host.settleLateDispatch({ @@ -198,7 +200,7 @@ describe('settling a send the provider proves it received after the ack window', await host.close(SESSION) await expect(settlement).resolves.toBeUndefined() await host.revealSession(SESSION) - expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }]) + expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) expect(dispatch).toHaveBeenCalledTimes(1) }) @@ -207,7 +209,9 @@ describe('settling a send the provider proves it received after the ack window', const params = sendParams('sent while a turn was running') await host.send(CALLER, params) await handedOver(params.envelope.clientOperationId) - await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'unknown' }])) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'unknown' }]) + ) await host.settleLateDispatch({ sessionId: SESSION, @@ -215,7 +219,7 @@ describe('settling a send the provider proves it received after the ack window', providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'late-uuid' } }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) // The point of the fix: the client stops rendering Retry, and Retry is what @@ -236,7 +240,7 @@ describe('settling a send the provider proves it received after the ack window', reason: DISPATCH_REJECTED_CANCELLED }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'rejected', @@ -267,7 +271,7 @@ describe('settling a send the provider proves it received after the ack window', { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } ) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted', @@ -279,7 +283,9 @@ describe('settling a send the provider proves it received after the ack window', it('leaves an already accepted send alone', async () => { const params = sendParams('ordinary send') await host.send(CALLER, params) - await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }])) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) + ) await host.settleLateDispatch({ sessionId: SESSION, @@ -287,7 +293,7 @@ describe('settling a send the provider proves it received after the ack window', providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'a-different-uuid' } }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index e7bd9a9ac85f..cd26b96e5cd1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -62,12 +62,11 @@ async function launchAndDeliver(): Promise<{ } // Accepted first; the delivery loop hands it over, and that outcome is what reached the agent. let dispatchState = sent.value.submission.dispatchState - await vi.waitFor(() => { + await vi.waitFor(async () => { dispatchState = - host - .journalSnapshot(created.value.sessionId) - .submissions.find((entry) => entry.clientMessageId === sent.value.clientMessageId) - ?.dispatchState ?? 'missing' + (await host.journalSnapshot(created.value.sessionId)).submissions.find( + (entry) => entry.clientMessageId === sent.value.clientMessageId + )?.dispatchState ?? 'missing' expect(dispatchState).not.toBe('pending') }) return { messageId, dispatchState } 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 3563b506db26..7c34b4f6b82d 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 @@ -26,7 +26,7 @@ import { const CALLER = { callerKey: 'client-1' } /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } @@ -77,15 +77,14 @@ async function delivered(text: string) { const sent = await send(text) expect(sent).toMatchObject({ ok: true }) const clientMessageId = sent.ok ? sent.value.clientMessageId : '' - const submission = () => - host - .journalSnapshot(SESSION) - .submissions.find((candidate) => candidate.clientMessageId === clientMessageId) - await eventually(() => - expect( - submission()?.dispatchState !== 'pending' || submission()?.handedOverAt !== undefined - ).toBe(true) - ) + const submission = async () => + (await host.journalSnapshot(SESSION)).submissions.find( + (candidate) => candidate.clientMessageId === clientMessageId + ) + await eventually(async () => { + const current = await submission() + expect(current?.dispatchState !== 'pending' || current?.handedOverAt !== undefined).toBe(true) + }) return submission() } @@ -243,17 +242,16 @@ describe('a record an older build left mid terminal handoff', () => { dispatchState: 'rejected' }) expect( - host - .journalSnapshot(SESSION) - .items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + (await host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) ).toHaveLength(1) expect(dispatch).not.toHaveBeenCalled() expect(stopOwnerProcess).not.toHaveBeenCalled() expect(acquire).not.toHaveBeenCalled() - // The user closes the terminal; the next open proves it gone and the chat takes over. + // The user closes the terminal; the next send's start proves it gone and the chat takes over. probe.mockResolvedValue({ outcome: 'pid-absent' }) - await host.hold(SESSION, 'surface-1') expect(stopOwnerProcess).not.toHaveBeenCalled() expect(await delivered('after the terminal closed')).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 853fa5784a0c..3eae8a7ae08f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -154,8 +154,8 @@ describe('structured session options and close', () => { }) // Accepted, then handed over by the delivery loop; the status is read once it answered. await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) - await vi.waitFor(() => - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') ) const events: AgentSessionStatusEvent[] = [] host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts new file mode 100644 index 000000000000..fd113dcdcfdb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -0,0 +1,121 @@ +// A chat's options, live or at rest. +// +// At rest nothing here needs a child. The pick is the record's own `options` — what the next start replays — +// and the list is the host's model catalog, the same one the picker already reads before a chat +// exists. A pick made at rest is written to the record as intent, through the same transition a live +// pick takes, so the next start applies it. + +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../shared/agent-session-wire' +import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' +import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' + +type RestingOptions = Pick + +async function readStructuredAgentSessionOptionsAtRest( + deps: Pick, + sessionId: string +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const catalog = (await deps.modelCatalog + ?.read({ agent: record.provider, sessionId }) + .catch(() => null)) ?? { origin: 'unknown' as const } + const models = catalog.origin === 'unknown' ? [] : catalog.models + const saved = record.options ?? {} + const fastMode = + saved.fastMode === undefined + ? null + : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) + return { + models, + ...(catalog.origin !== 'unknown' && catalog.fastModeSupport + ? { fastModeSupport: catalog.fastModeSupport } + : {}), + current: { + // An unknown model is one the client already treats as unconfirmed. + model: saved.model ?? models.find((model) => model.isDefault)?.id ?? '', + ...(saved.effort ? { effort: saved.effort } : {}), + ...(typeof fastMode === 'boolean' ? { fastMode } : {}) + } + } +} + +/** Records a pick for the next start. Only a key the provider would accept is kept. */ +export async function recordStructuredAgentSessionOptionIntent( + store: Pick, + ctx: Pick, + input: { key: string; value: string } +): Promise> { + const record = store.getRecord(ctx.sessionId) + const accepted = + record?.provider === 'codex' + ? isCodexTurnOptionKey(input.key) + : record?.provider === 'claude' && isClaudeStructuredOptionKey(input.key) + if (!record || !accepted) { + return { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: `${record?.provider ?? 'This session'} has no session option named ${input.key}` + } + } + } + const options = { ...record.options, [input.key]: input.value } + await ctx.persistOptions(options) + ctx.publish() + return { ok: true, value: { ...input, options } } +} + +/** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */ +export async function readStructuredAgentSessionOptions( + context: Pick< + StructuredAgentSessionMutationContext, + 'deps' | 'serialize' | 'openConversation' | 'conversation' + >, + sessionId: string +): Promise { + const { adapter, store } = context.deps + const live = await context.serialize(sessionId, async () => { + const session = await context.openConversation(sessionId) + const child = session?.child + if (!child) { + return null + } + if (!adapter.readOptions) { + throw new Error('structured_agent_session_options_unsupported') + } + return adapter.readOptions({ sessionId, fence: child.fence }) + }) + const options = live ?? (await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId)) + // Re-acquired after the reads above: the handle they saw may have closed and reopened since. + const session = await context.conversation(sessionId) + const phase = store.getRecord(sessionId)?.rewind?.phase + const agent = session.params.provider + return { + ...options, + rewind: + phase === 'prepared' || phase === 'provider-succeeded' + ? { supported: false, reason: 'outcome-unknown' } + : (adapter.rewindSupport?.(sessionId, agent) ?? { + supported: false, + reason: 'unsupported' + }), + conversationCommands: adapter.compact ? ['clear', 'compact'] : ['clear'], + ...(adapter.supportsThreadGoal?.(sessionId, agent) + ? { threadGoal: { current: session.journal.threadGoal() } } + : {}), + ...(adapter.recordsContextUsage?.(sessionId, agent) + ? { contextUsage: { current: session.journal.contextUsage() } } + : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 51f74871dc28..3cc24d65e8b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -19,6 +19,7 @@ import { import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { @@ -84,7 +85,6 @@ function startHost(): void { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: 60_000, now: () => NOW }) } @@ -160,12 +160,12 @@ function stop() { }) } -function submission(id: string): AgentJournalSubmission | undefined { - return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find((entry) => entry.clientMessageId === id) } -function statusRows(): { itemId: string; text: string; tone?: string }[] { - return host.journalSnapshot(SESSION).items.flatMap((item) => +async function statusRows(): Promise<{ itemId: string; text: string; tone?: string }[]> { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [ { @@ -211,9 +211,9 @@ function conversation() { return host['sessions'].get(SESSION) } -function subscribe(): AgentSessionSubscribeEvent[] { +async function subscribe(): Promise { const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(structuredClone(event)) @@ -244,7 +244,7 @@ function deferred() { } describe('Stop on a child still proving its start', () => { - it('ends the child and keeps the conversation, its holders and its readers (R1)', async () => { + it('ends the child and keeps the conversation and its readers (R1)', async () => { const ended = deferred() adapterExtras = { awaitStarted: vi.fn(() => ended.promise), @@ -255,32 +255,30 @@ describe('Stop on a child still proving its start', () => { } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) - await host.hold(SESSION, 'surface-1', { resume: false }) const first = await accept('hello') const journal = conversation()?.journal - const events = subscribe() + const events = await subscribe() const frames = watchStatus() await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - // The same conversation: no reopen, the holder kept, and the chat told it is idle again. + // The same conversation: no reopen, and the chat told it is idle again. expect(conversation()?.journal).toBe(journal) expect(conversation()?.child).toBeNull() expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) - expect(host.isHeld(SESSION)).toBe(true) expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') - expect(submission(first)).toMatchObject({ + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) // A Stop is not a failure: no row, and the loop is gone. - expect(statusRows()).toEqual([]) + expect(await statusRows()).toEqual([]) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) const next = await accept('after stop') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(conversation()?.journal).toBe(journal) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) // The reader opened before the Stop saw the next message delivered on the same stream. @@ -310,12 +308,12 @@ describe('a settlement retry for an earlier child inside the attach for the next const retryFence = store.getRecord(SESSION)!.lease.runtimeFence const id = await accept('for the next child') - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined() // Handed over at the new child's fence, which the attach reserved after the retry. const newFence = store.getRecord(SESSION)!.lease.runtimeFence expect(newFence).toBeGreaterThan(retryFence) - expect(submission(id)?.fence).toBe(newFence) + expect((await submission(id))?.fence).toBe(newFence) expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) }) }) @@ -332,7 +330,7 @@ describe('a published child that dies while it proves its start', () => { await restartHost() acquire.mockImplementation(spawnStartingChild) const first = await accept('first') - const events = subscribe() + const events = await subscribe() const second = await accept('second') await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalled()) const child = currentChild() @@ -342,20 +340,20 @@ describe('a published child that dies while it proves its start', () => { settled.resolve(TEXT) } else { settled.resolve(TEXT) - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) await exit(child, EXIT, true) } - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) - expect(statusRows()).toEqual([ + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + expect(await statusRows()).toEqual([ { itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, text: TEXT, tone: 'error' } ]) - expect(submission(first)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) - expect(submission(second)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) + expect(await submission(second)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) expect(rejectedIn(events, second)).toBe(true) expect(dispatch).not.toHaveBeenCalled() expect(acquire).toHaveBeenCalledTimes(2) @@ -373,12 +371,12 @@ describe('a child that ends before its message is handed over', () => { } await restartHost() const id = await accept('hello') - const events = subscribe() + const events = await subscribe() - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)?.reason).toContain('codex app-server crashed') - expect(statusRows()).toEqual([ - { itemId: expect.any(String), text: submission(id)?.reason, tone: 'error' } + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect((await submission(id))?.reason).toContain('codex app-server crashed') + expect(await statusRows()).toEqual([ + { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error' } ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) @@ -407,8 +405,10 @@ describe('another child indexed while the loop waits on the one it started', () const stopped = currentChild() expect(await stop()).toMatchObject({ ok: true }) const second = await accept('second') - // A view's hold starts its own child before the loop's handover step runs. - await host.hold(SESSION, 'surface-1') + // An operation that needs the agent starts its own child before the loop's handover step runs. + await host['serialize'](SESSION, () => + ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) + ) const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) @@ -424,9 +424,9 @@ describe('another child indexed while the loop waits on the one it started', () }) startOf(replacement.acquisitionGeneration).resolve() - await eventually(() => expect(submission(second)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) - expect(submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) }) }) @@ -435,7 +435,7 @@ describe('a quit with a message still queued', () => { async function afterRelaunch(id: string): Promise { startHost() await host.revealSession(SESSION) - return submission(id) + return await submission(id) } it('settles a message no child ever had the way a chat close does (R2)', async () => { @@ -491,7 +491,7 @@ describe('a send whose start failed, sent again with the same operation id', () value: { submission: { dispatchState: 'pending' } } }) const id = params.envelope.clientOperationId - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) // The start moved the fence while the message was out. expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) const starts = acquire.mock.calls.length @@ -538,9 +538,9 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await stop()).toMatchObject({ ok: true }) }) - await eventually(() => expect(submission(second)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) - expect(statusRows()).toEqual([]) + expect(await statusRows()).toEqual([]) }) it('fails the start after a host stop, with the stop as the reason (R2)', async () => { @@ -554,9 +554,9 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) ) - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) - expect(submission(second)?.reason).toBe(reason) - expect(statusRows()).toEqual([{ itemId: expect.any(String), text: reason, tone: 'error' }]) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + expect((await submission(second))?.reason).toBe(reason) + expect(await statusRows()).toEqual([{ itemId: expect.any(String), text: reason, tone: 'error' }]) expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index c8a2e4bba14f..ced2d2359216 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -60,7 +60,7 @@ function createHost( async function abandonHost(host: StructuredAgentSessionHost): Promise { host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() + host['lifetime'].dispose() await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) host['sessions'].clear() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index bb5d29d4dead..7c195a9a1243 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -80,8 +80,8 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -function claudeParams() { - return hostTestAttachParams(null, { +function claudeParams(expectedRuntimeFence: number | null = null) { + return hostTestAttachParams(expectedRuntimeFence, { provider: 'claude', agent: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, @@ -131,8 +131,10 @@ describe('a publish-first Claude create whose init is slow', () => { await host.close(SESSION) const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - // Reopening the chat: the surface's first hold resumes the session. - await host.hold(SESSION, 'chat-1') + // Starting the chat again resumes the session under a new fence. + await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ + ok: true + }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) // The new child's init reports its CLI default; the saved pick is restored over it. expect(store.getRecord(SESSION)?.options?.model).toBe('opus') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 6d6d910cc551..8f3c0390aaa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -24,7 +24,6 @@ export type StructuredAgentSessionProviderStartedContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus?: (sessionId: string) => void - restartReleaseGrace: (sessionId: string) => void onBarrierError: (sessionId: string, error: unknown) => void } @@ -44,9 +43,6 @@ export function settleStructuredAgentSessionProviderStarted( ) { return } - // Prompts held for the start are written now but open a turn only on their echo; a release - // tick in between would stop the child before it runs them. - context.restartReleaseGrace(event.sessionId) try { await persistStartedOptions(context, event) } catch (error) { 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 index ed10f63e44ce..06bd043aec0c 100644 --- 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 @@ -1,9 +1,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionReadRestoreDeps } from './structured-agent-session-restart-restore' -import { - restoreOneStructuredAgentSessionRead, - restoreStructuredAgentSessionsOnRestart -} from './structured-agent-session-restart-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' export class StructuredAgentSessionReadableRestorer { private restorePromise: Promise | null = null @@ -22,30 +19,6 @@ export class StructuredAgentSessionReadableRestorer { return this.restorePromise } - /** - * One session, on demand, after startup. - * - * Deliberately outside `restorePromise`: that latch answers "has the startup sweep run", and a - * surface asking for a session the sweep never covered — or that was closed since — must not be - * told yes because the sweep finished. Needs no dedupe of its own; the per-session task queue - * `serialize` runs on already orders concurrent callers, and the second one sees `hasSession`. - * - * Provider-agnostic by construction: eligibility is `supportsRecord`, which the adapter router - * answers for Claude and Codex from the record's own provider. - */ - async restoreOne(sessionId: string): Promise { - if (!this.supports(sessionId)) { - return false - } - await restoreOneStructuredAgentSessionRead(this.input, sessionId) - return this.input.hasSession(sessionId) - } - - private supports(sessionId: string): boolean { - const record = this.input.openDeps.store.getRecord(sessionId) - return record !== null && this.input.supportsRecord(record) - } - private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { const targetOrder = sessionIds ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) 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 7cd92bf521bf..1a5b53ac61fc 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 @@ -59,6 +59,12 @@ async function stopOwner(child: ReturnType): Promise spawnedOwners.delete(child) } +/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +async function startAgent(): Promise { + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect((await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) +} + function adapter(): StructuredAgentSessionAdapter { return { acquire, @@ -84,7 +90,7 @@ function openHost(overrides: Partial = {}): void async function abandonHost(abandonedHost: StructuredAgentSessionHost): Promise { abandonedHost['runtimeState'].stopLeaseRenewal() - abandonedHost['holds'].dispose() + abandonedHost['lifetime'].dispose() await Promise.all( [...abandonedHost['sessions'].values()].map((session) => session.journal.close()) ) @@ -197,8 +203,8 @@ describe('recovery exits', () => { runtimeFence: 4 }) - // The ordinary native recovery path remains: the first surface hold resumes it. - await host.hold(SESSION, 'surface-1') + // The ordinary native recovery path remains: the next start resumes it. + await startAgent() expect(acquire).toHaveBeenCalledTimes(3) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', @@ -242,7 +248,7 @@ describe('recovery exits', () => { }) }) - it('heals a stranded native owner during startup restore, and spawns nothing until a surface asks', async () => { + it('heals a stranded native owner during startup restore, and spawns nothing until work asks', async () => { expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() @@ -262,7 +268,7 @@ describe('recovery exits', () => { await host.restoreReadableSessions() // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but - // nothing has asked to look at this session, so no replacement child exists yet. + // nothing has asked this session for work, so no replacement child exists yet. expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') expect(acquire).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -271,7 +277,7 @@ describe('recovery exits', () => { ownerProcess: null }) - await host.hold(SESSION, 'surface-1') + await startAgent() expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -340,7 +346,7 @@ describe('recovery exits', () => { } }) - await host.hold(SESSION, 'surface-overlap') + await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 3, @@ -348,6 +354,6 @@ describe('recovery exits', () => { handoffStage: null, ownerProcess: { pid: replacement.process.pid, spawnToken: 'spawn-b' } }) - expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 0d8733898909..8f686aeab7a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -105,7 +105,7 @@ async function createHarness(options: { attached?: boolean } = {}) { async function abandonHost(host: StructuredAgentSessionHost): Promise { host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() + host['lifetime'].dispose() host['conversationDelivery'].loop.dispose() await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) host['sessions'].clear() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts deleted file mode 100644 index 1cce910c09d6..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts +++ /dev/null @@ -1,84 +0,0 @@ -// The delay between "nothing holds this session and nothing has happened in it" and "stop its -// provider child". -// -// It is an IDLE window, not a short grace. A surface that reconnects — a mobile socket dropping on -// a network switch, a renderer remounting a tab, a worktree switch hiding the pane — releases and -// re-holds, and a send to a chat nobody is looking at restarts its owner; stopping the child soon -// after either costs the user a respawn plus a resume on the next message. And a turn the user -// already asked for must finish: stopping the child mid-answer strands the open turn marker. -// -// So the clock arms when the last holder leaves, every journal write while it is armed starts it -// again, and a tick that finds work still owed — a turn running, or a message sent but not yet -// taken by the provider — re-arms instead of evicting. The child goes only after a full window -// with no holder and no owed work. Quit still stops every child at once. - -export const STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS = 30 * 60_000 - -export type StructuredAgentSessionReleaseClockDeps = { - /** Never evict while work is owed; a true answer re-arms the clock instead. */ - hasOwedWork: (sessionId: string) => boolean - /** Re-checked at fire time: a holder may have arrived while the timer ran. */ - isHeld: (sessionId: string) => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void - graceMs?: number -} - -export class StructuredAgentSessionReleaseClock { - private readonly timers = new Map>() - private readonly graceMs: number - - constructor(private readonly deps: StructuredAgentSessionReleaseClockDeps) { - this.graceMs = deps.graceMs ?? STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS - } - - arm(sessionId: string): void { - this.cancel(sessionId) - const timer = setTimeout(() => { - this.timers.delete(sessionId) - this.fire(sessionId) - }, this.graceMs) - // A pending release must never be the reason a process stays alive at quit. - timer.unref?.() - this.timers.set(sessionId, timer) - } - - /** Activity in an unheld session: the idle window starts over. */ - renew(sessionId: string): void { - if (this.timers.has(sessionId)) { - this.arm(sessionId) - } - } - - cancel(sessionId: string): void { - const timer = this.timers.get(sessionId) - if (timer) { - clearTimeout(timer) - this.timers.delete(sessionId) - } - } - - isArmed(sessionId: string): boolean { - return this.timers.has(sessionId) - } - - dispose(): void { - for (const timer of this.timers.values()) { - clearTimeout(timer) - } - this.timers.clear() - } - - private fire(sessionId: string): void { - if (this.deps.isHeld(sessionId)) { - return - } - if (this.deps.hasOwedWork(sessionId)) { - this.arm(sessionId) - return - } - void this.deps.evict(sessionId).catch((error: unknown) => { - this.deps.onError?.({ sessionId, error }) - }) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts new file mode 100644 index 000000000000..54c28b4b0be9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -0,0 +1,233 @@ +// A real host over a real store and journal, with a scripted provider and a clock the test moves, +// for the tests of a conversation that outlives its agent. The idle sweep runs on its own short +// interval; a test moves `clock.now` past the idle window and waits for the outcome. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionMutationEnvelope, + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { + STRUCTURED_AGENT_SESSION_IDLE_MS, + StructuredAgentSessionIdleSweep +} from './structured-agent-session-idle-sweep' + +export const REST_TEST_CALLER = { callerKey: 'client-1' } +export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS +export const SWEEP_INTERVAL_MS = 5 + +export type RestTestAdapter = { + acquire: Mock + closeSession: Mock> + dispatch: Mock + acknowledgeSessionRelease: Mock< + NonNullable + > + backgroundTaskState: Mock> + readOptions: Mock> +} + +export type RestTestRig = { + root: string + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + adapter: RestTestAdapter + clock: { now: number } + statusEvents: AgentSessionStatusEvent[] + sink: { publish: Mock; forget: Mock } + /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ + restart: (deps?: Partial) => Promise + dispose: () => Promise +} + +let ordinal = 0 +let generations = 0 + +export function acceptedDispatch(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: `turn-${ordinal}`, ordinal } + } +} + +export function restTestSend( + text: string, + fence = 1 +): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { + const body = hostTestMessage(text) + return { + body, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + } +} + +/** Every item and submission a reader was sent, whatever frame carried it. */ +export function readerSaw(events: readonly AgentSessionSubscribeEvent[]) { + const items = events.flatMap((event) => + event.type === 'batch' ? event.batch.items : event.type === 'end' ? [] : event.page.items + ) + const submissions = events.flatMap((event) => + event.type === 'batch' ? event.batch.submissions : [] + ) + return { + texts: items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ), + submissions + } +} + +export function collectSubscriber(): { + events: AgentSessionSubscribeEvent[] + emit: (event: AgentSessionSubscribeEvent) => void +} { + const events: AgentSessionSubscribeEvent[] = [] + return { events, emit: (event) => events.push(event) } +} + +export async function createRestTestRig( + deps: Partial = {} +): Promise { + resetHostTestOperationIds() + ordinal = 0 + const root = await mkdtemp(join(tmpdir(), 'orca-rest-')) + const clock = { now: HOST_TEST_NOW } + const statusEvents: AgentSessionStatusEvent[] = [] + const sink = { publish: vi.fn(), forget: vi.fn() } + let store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const adapter: RestTestAdapter = { + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${++generations}`, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: clock.now + } + })), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => acceptedDispatch()), + acknowledgeSessionRelease: vi.fn(), + backgroundTaskState: vi.fn(() => undefined), + readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) + } + const hostFor = (overrides: Partial) => + new StructuredAgentSessionHost({ + store, + adapter: { + ...adapter, + releaseAcquisition: vi.fn(async () => true), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async ({ commit }) => commit(), + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => clock.now, + statusSink: sink, + idleSweep: { intervalMs: SWEEP_INTERVAL_MS }, + ...deps, + ...overrides + }) + const rig: RestTestRig = { + root, + store, + host: hostFor({}), + adapter, + clock, + statusEvents, + sink, + restart: async (overrides = {}) => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + rig.store = store + rig.host = hostFor(overrides) + rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return rig.host + }, + dispose: async () => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + await rm(root, { recursive: true, force: true }) + } + } + rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return rig +} + +/** Creates the chat, lists its tab, and sends one message so its journal is on disk. */ +export async function foundRestTestChat(rig: RestTestRig): Promise { + const attached = await rig.host.attach(REST_TEST_CALLER, hostTestAttachParams(null)) + if (!attached.ok) { + throw new Error(`attach refused: ${attached.refusal.code}`) + } + await rig.store.setSessionTabVisibility(SESSION, true) + const sent = await rig.host.send(REST_TEST_CALLER, restTestSend('hello', attached.fence)) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalled()) +} + +/** Runs one sweep pass now, for a test that set `idleSweep.intervalMs` out of reach. */ +export function sweepOnce(host: StructuredAgentSessionHost): Promise { + const lifetime: unknown = Reflect.get(host, 'lifetime') + const sweep: unknown = + typeof lifetime === 'object' && lifetime !== null ? Reflect.get(lifetime, 'idleSweep') : null + if (!(sweep instanceof StructuredAgentSessionIdleSweep)) { + throw new Error('the host has no idle sweep') + } + return sweep.tick() +} + +/** Waits long enough for several sweep ticks to have run. */ +export function sweepTicks(count = 6): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_INTERVAL_MS * count)) +} + +export { SESSION as REST_TEST_SESSION, THREAD as REST_TEST_THREAD } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts new file mode 100644 index 000000000000..fc5c25dea0a5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -0,0 +1,85 @@ +// "Resume all" starts agents a few at a time. A continuation is a send, so a chat counts as started +// once its agent took the message or its start failed — not when the message was accepted, which +// is instant and would let every agent start at once. + +import { expect, it, vi } from 'vitest' +import { + startStructuredAgentSessionContinuation, + type StructuredAgentSessionContinuationDeps +} from './structured-agent-session-restart-continuation' +import { + resumeStructuredAgentSessionsFromRestart, + STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY, + StructuredAgentSessionResumeAdmission +} from './structured-agent-session-restart-resume-runner' +import { marker } from './structured-agent-session-restart-resume-test-harness' + +type Handover = { dispatchState: string; reason?: string | null } | undefined + +it('holds each slot from accept until handover or rejection, and frees it when the wait ends (P2-27)', async () => { + const handovers = new Map>() + let waiting = 0 + let mostWaiting = 0 + const deps = (sessionId: string): StructuredAgentSessionContinuationDeps => ({ + currentFence: () => 1, + send: vi.fn(async () => ({ ok: true, value: { submission: { dispatchState: 'pending' } } })), + awaitHandedOver: async () => { + const handover = Promise.withResolvers() + handovers.set(sessionId, handover) + waiting += 1 + mostWaiting = Math.max(mostWaiting, waiting) + try { + return await handover.promise + } finally { + waiting -= 1 + } + }, + awaitSettlement: async () => ({ dispatchState: 'accepted' }), + note: async () => undefined, + onNoteFailed: () => undefined + }) + const sessions = Array.from({ length: 8 }, (_, index) => `session-${index}`) + const outcomes = resumeStructuredAgentSessionsFromRestart( + { + admission: new StructuredAgentSessionResumeAdmission(), + consumeMarker: async () => true, + resume: async (sessionId) => { + const started = await startStructuredAgentSessionContinuation( + deps(sessionId), + sessionId, + marker() + ) + if ('done' in started && started.done.outcome === 'refused') { + throw new Error(started.done.reason ?? 'refused') + } + } + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runner reads only each candidate's session id. + sessions.map((sessionId) => ({ sessionId }) as never), + 'modal' + ) + const settle = async (sessionId: string, handover: Handover) => { + await vi.waitFor(() => expect(handovers.has(sessionId)).toBe(true)) + handovers.get(sessionId)!.resolve(handover) + } + + await vi.waitFor(() => expect(waiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY)) + // Accepted but not yet handed over: a slow start keeps its slot. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(handovers.size).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + await settle('session-0', { dispatchState: 'pending' }) + await settle('session-1', { dispatchState: 'rejected', reason: 'Codex could not start.' }) + // The session closed, or too many waited: the wait proves nothing, and the slot is freed. + await settle('session-2', undefined) + for (const sessionId of sessions.slice(3)) { + await settle(sessionId, { dispatchState: 'pending' }) + } + + const results = await outcomes + expect(mostWaiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(results.find((result) => result.sessionId === 'session-1')).toMatchObject({ + outcome: 'refused', + reason: 'Codex could not start.' + }) + expect(results.filter((result) => result.outcome === 'resumed')).toHaveLength(7) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts index 63c778144eef..a7f91be54a0b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts @@ -1,7 +1,10 @@ // A chat whose main agent had finished while its subagents still ran, across a restart. import { expect, it, vi } from 'vitest' -import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { + interruptedRestart, + startAgent +} from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD @@ -24,8 +27,9 @@ it('offers a chat whose subagent the restart stopped, named from the stop-time s // On resume the provider restates what it lost in its own words, rewriting the row before the // continuation is dispatched. What the offer was admitted for still stands. it('continues a stopped subagent after the provider restates its row', async () => { - const { host, acquire, dispatch } = await interruptedRestart('children') - await host.hold(SESSION, 'pane') + const state = await interruptedRestart('children') + const { host, acquire, dispatch } = state + await startAgent(state) const events = acquire.mock.calls[0]?.[0].events if (!events) { throw new Error('missing resumed provider event sink') @@ -54,13 +58,13 @@ it('continues a stopped subagent after the provider restates its row', async () (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) // The user opens the chat before choosing Resume, and the reattached provider restates its row. it('still offers and continues a chat the user opened before resuming', async () => { - const { host, acquire, dispatch } = await interruptedRestart('children') - await host.hold(SESSION, 'pane') + const state = await interruptedRestart('children') + const { host, acquire, dispatch } = state + await startAgent(state) const events = acquire.mock.calls[0]?.[0].events if (!events) { throw new Error('missing resumed provider event sink') @@ -87,5 +91,4 @@ it('still offers and continues a chat the user opened before resuming', async () (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts index 8ab9bb250ad5..cfbdd9ce35e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -41,8 +41,6 @@ describe('the restart continuation waits for its delivery (W18)', () => { const surfaces = structuredAgentSessionRestartResumeSurfaces( { revealSession: async () => ({ readable: true }), - hold: async () => undefined, - release: () => undefined, send: async () => { throw new Error('not used') }, @@ -61,3 +59,54 @@ describe('the restart continuation waits for its delivery (W18)', () => { }) }) }) + +describe('a restart batch holds a chat until its continuation is handed over', () => { + function surfacesOver(settlement: StructuredAgentSessionSendSettlement) { + return structuredAgentSessionRestartResumeSurfaces( + { + revealSession: async () => ({ readable: true }), + send: async () => { + throw new Error('not used') + }, + waitForSendSettlement: settlement.wait + }, + () => 0 + ) + } + + it('keeps waiting through a slow start and resolves once the agent takes the message', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + let released = false + const handedOver = surfacesOver(settlement) + .awaitSendHandedOver(SESSION, MESSAGE) + .then((result) => { + released = true + return result + }) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(released).toBe(false) + // Handed over: still pending at the provider, no longer queued. + submission = { ...submission, handedOverAt: 2 } + settlement.publish(SESSION, journal) + await expect(handedOver).resolves.toMatchObject({ + value: { submission: { dispatchState: 'pending', handedOverAt: 2 } } + }) + }) + + it('resolves once the start fails and the message is rejected', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const handedOver = surfacesOver(settlement).awaitSendHandedOver(SESSION, MESSAGE) + submission = { ...submission, dispatchState: 'rejected', reason: 'Codex could not start.' } + settlement.publish(SESSION, journal) + await expect(handedOver).resolves.toMatchObject({ + value: { submission: { dispatchState: 'rejected' } } + }) + }) + + it('resolves undefined when the session closes first', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const handedOver = surfacesOver(settlement).awaitSendHandedOver(SESSION, MESSAGE) + settlement.closeSession(SESSION) + await expect(handedOver).resolves.toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 2d558f8f0287..0172cdd69500 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -6,13 +6,22 @@ import { AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { - continueStructuredAgentSessionAfterRestart, RestartContinuationSupersededError, + startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationDeps } from './structured-agent-session-restart-continuation' +/** The whole continuation: handed over, then its verdict. */ +async function continueStructuredAgentSessionAfterRestart( + ...args: Parameters +) { + const started = await startStructuredAgentSessionContinuation(...args) + return 'done' in started ? started.done : started.verdict() +} + function dependencies( - settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected' + settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected', + handedOver: 'pending' | 'rejected' | undefined = 'pending' ): StructuredAgentSessionContinuationDeps & { note: ReturnType send: ReturnType @@ -27,6 +36,14 @@ function dependencies( dispatchState: settledDispatch, reason: settledDispatch === 'rejected' ? 'provider_refused' : null })), + awaitHandedOver: vi.fn(async () => + handedOver + ? { + dispatchState: handedOver, + reason: handedOver === 'rejected' ? 'Codex could not start.' : null + } + : undefined + ), note: vi.fn(async () => undefined), onNoteFailed: vi.fn() } @@ -106,3 +123,40 @@ it('reports an unattached chat without sending', async () => { }) expect(deps.send).not.toHaveBeenCalled() }) + +// A start that failed rejects the continuation before any provider saw it: that is the verdict. +it('reports a continuation rejected at handover without waiting for the provider', async () => { + const deps = dependencies('accepted', 'rejected') + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', reason: 'Codex could not start.' }) + expect(deps.awaitSettlement).not.toHaveBeenCalled() + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) +}) + +// The start completes once the agent took the message; the provider's answer is a later verdict. +it('returns at handover and leaves the provider verdict to be awaited', async () => { + const deps = dependencies('accepted') + + const started = await startStructuredAgentSessionContinuation(deps, SESSION, marker()) + + expect(deps.awaitHandedOver).toHaveBeenCalledOnce() + expect(deps.awaitSettlement).not.toHaveBeenCalled() + if (!('verdict' in started)) { + throw new Error('expected a handed-over continuation') + } + await expect(started.verdict()).resolves.toEqual({ sessionId: SESSION, outcome: 'continued' }) + expect(deps.note).toHaveBeenCalledOnce() +}) + +// A handover wait that ends without an answer — the session closed, or too many waited — proves +// nothing, so the provider's verdict still decides. +it('still awaits the verdict when the handover wait ends unanswered', async () => { + const deps = dependencies('unknown', undefined) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + ).resolves.toEqual({ sessionId: SESSION, outcome: 'unknown' }) + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...UNCONFIRMED) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 87a56efb7b95..61009b451501 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -1,9 +1,10 @@ // Asking an interrupted agent to carry on, on the user's opt-in. // -// Reattaching and continuing are SEPARATE operations: `resume` reattaches and sends nothing; this -// adds one message on top of it. Both the restart prompt and an opted-in launch come here, so a -// SETTING can reach this send — acceptable because the work is the user's own, the message asks the -// agent to verify its last action before repeating it, and the launch toast reports what happened. +// The continuation is a send like any other: accepted into the conversation, and delivered by the +// session's delivery loop, which starts the agent. Both the restart prompt and an opted-in launch +// come here, so a SETTING can reach this send — acceptable because the work is the user's own, the +// message asks the agent to verify its last action before repeating it, and the launch toast +// reports what happened. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { @@ -45,7 +46,8 @@ export type StructuredAgentSessionContinuationOutcome = { /** The slice of the host one continuation needs. Structural so this module never imports the host. */ export type StructuredAgentSessionContinuationHost = { sessions: ReadonlyMap - /** The fence a conversation write carries; null when the session is not open. */ + /** The fence a conversation write carries; null when this host has no record of the session. + * The send opens the conversation itself, so a closed one still answers. */ conversationFence: (sessionId: string) => number | null send: (input: { envelope: AgentSessionMutationEnvelope @@ -56,6 +58,10 @@ export type StructuredAgentSessionContinuationHost = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> + awaitSendHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void now: () => number /** Whether the marker is still an offer, with the continuation's own submission set aside. @@ -90,23 +96,13 @@ export function restartContinuationDeps( }), awaitSettlement: async (sessionId, clientMessageId) => (await host.awaitSendSettlement(sessionId, clientMessageId))?.value.submission, + awaitHandedOver: async (sessionId, clientMessageId) => + (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value.submission, onNoteFailed: host.onNoteFailed, note: restartNoteWriter(host) } } -/** The note for a reattach that failed before any continuation was attempted. */ -export function noteRestartReattachFailed( - host: StructuredAgentSessionContinuationHost, - sessionId: string -): Promise { - return noteNotContinued( - { note: restartNoteWriter(host), onNoteFailed: host.onNoteFailed }, - sessionId, - 'not-connected' - ) -} - /** Writes a host-authored status note into the chat. */ function restartNoteWriter( host: Pick @@ -183,8 +179,10 @@ export function restartContinuationEnvelope( } } +type ContinuationSubmission = { dispatchState?: string; reason?: string | null } + export type StructuredAgentSessionContinuationDeps = { - /** Runtime fence as it stands now; null when the session is not attached. */ + /** Runtime fence as it stands now; null when this host has no record of the session. */ currentFence: (sessionId: string) => number | null send: (input: { envelope: AgentSessionMutationEnvelope @@ -205,7 +203,13 @@ export type StructuredAgentSessionContinuationDeps = { awaitSettlement: ( sessionId: string, clientMessageId: string - ) => Promise<{ dispatchState?: string; reason?: string | null } | undefined> + ) => Promise + /** Waits until the send is handed to a started agent or rejected. Undefined when the wait ended + * first — the session closed, or too many waited — which proves neither. */ + awaitHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise /** Records a host-authored journal note: that this send was Orca's, not the user's, or that the * chat did not carry on. `tone` is a display hint older clients render as plain text. */ note: (sessionId: string, text: string, tone?: 'error' | 'warning') => Promise @@ -214,26 +218,56 @@ export type StructuredAgentSessionContinuationDeps = { onNoteFailed: (sessionId: string, error: unknown) => void } +/** A continuation handed to its agent, or already decided. */ +export type StartedStructuredAgentSessionContinuation = + | { done: StructuredAgentSessionContinuationOutcome } + | { verdict: () => Promise } + /** - * Sends the continuation to ONE already-reconnected session. - * - * The caller must have reconnected it first: this deliberately does not reconnect, so that every - * eligibility check, the admission gate and the consume-once ordering stay in the resume path and - * are not re-implemented here. + * Sends the continuation to ONE session and returns once the agent has taken it or its start + * failed — the point a restart batch counts a start as done. What the provider then answered is + * the `verdict`, awaited separately so a slow answer does not hold the batch. */ -export async function continueStructuredAgentSessionAfterRestart( +export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, marker: AgentSessionResumeMarker -): Promise { - let result: StructuredAgentSessionContinuationOutcome +): Promise { + let started: StartedStructuredAgentSessionContinuation try { - result = await sendContinuation(deps, sessionId, marker) + started = await sendContinuation(deps, sessionId, marker) } catch (error) { await noteNotContinued(deps, sessionId, 'refused') throw error } - if (result.outcome !== 'continued') { + if ('done' in started) { + await noteOutcome(deps, sessionId, started.done) + return started + } + const { verdict } = started + return { + verdict: async () => { + const outcome = await verdict() + await noteOutcome(deps, sessionId, outcome) + return outcome + } + } +} + +async function noteOutcome( + deps: Pick, + sessionId: string, + result: StructuredAgentSessionContinuationOutcome +): Promise { + if (result.outcome === 'continued') { + // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to + // carry on. Best effort — losing it must not turn a delivered continuation into a failure. + try { + await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) + } catch (error) { + deps.onNoteFailed(sessionId, error) + } + } else { await noteNotContinued( deps, sessionId, @@ -244,7 +278,6 @@ export async function continueStructuredAgentSessionAfterRestart( : 'refused' ) } - return result } /** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, @@ -273,10 +306,10 @@ async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, marker: AgentSessionResumeMarker -): Promise { +): Promise { const fence = deps.currentFence(sessionId) if (fence === null) { - return { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } + return { done: { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } } } const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker) const sent = await deps.send({ envelope, body }).catch((error: unknown) => { @@ -288,46 +321,61 @@ async function sendContinuation( return null }) if (!sent) { - return { sessionId, outcome: 'unknown' } + return { done: { sessionId, outcome: 'unknown' } } } if (!sent.ok) { return { - sessionId, - outcome: 'refused', - reason: sent.refusal?.code ?? 'agent_session_send_failed' + done: { + sessionId, + outcome: 'refused', + reason: sent.refusal?.code ?? 'agent_session_send_failed' + } } } - // The send result carries the dispatch as it stood when Orca took the message, which for a normal - // successful send is `pending`. Judging it here would report every delivered continuation as - // pending and never write the note, so the settled value is what decides. - const submission = - (await deps.awaitSettlement(sessionId, envelope.clientOperationId).catch(() => undefined)) ?? - sent.value?.submission + const clientMessageId = envelope.clientOperationId + const handedOver = await deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined) + if (handedOver?.dispatchState === 'rejected') { + return { done: refusedBy(sessionId, handedOver) } + } + return { + verdict: async () => + verdictOf( + sessionId, + // The send result carries the dispatch as it stood when Orca took the message, which for + // a normal successful send is `pending`, so the settled value is what decides. + (await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ?? + handedOver ?? + sent.value?.submission + ) + } +} + +function refusedBy( + sessionId: string, + submission: ContinuationSubmission +): StructuredAgentSessionContinuationOutcome { + return { + sessionId, + outcome: 'refused', + reason: submission.reason ?? 'agent_session_dispatch_rejected' + } +} + +function verdictOf( + sessionId: string, + submission: ContinuationSubmission | undefined +): StructuredAgentSessionContinuationOutcome { const dispatch = submission?.dispatchState - if (dispatch === 'rejected') { - return { - sessionId, - outcome: 'refused', - reason: submission?.reason ?? 'agent_session_dispatch_rejected' - } + if (submission && dispatch === 'rejected') { + return refusedBy(sessionId, submission) } if (dispatch === 'pending') { // Still pending after settlement gave up: handed off, never confirmed. return { sessionId, outcome: 'pending' } } - if (dispatch !== 'accepted') { - // `unknown`, or a peer that reported no state at all. Delivery is unverifiable, so this claims - // neither success nor failure — and writes no note saying the agent was asked to continue. - return { sessionId, outcome: 'unknown' } - } - // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to - // carry on, and it must not sit beside a message the provider refused or never confirmed. Best - // effort beyond that — losing the note must not turn a delivered continuation into a failure — - // but reported, never swallowed. - try { - await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) - } catch (error) { - deps.onNoteFailed(sessionId, error) - } - return { sessionId, outcome: 'continued' } + // `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims + // neither success nor failure — and writes no note saying the agent was asked to continue. + return dispatch === 'accepted' + ? { sessionId, outcome: 'continued' } + : { sessionId, outcome: 'unknown' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 97830212b0a5..afbf0dc37b98 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -2,16 +2,17 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { interruptedRestart, + startAgent, statusNotes } from './structured-agent-session-restart-interruption-test-harness' +import { hostTestState } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -36,7 +37,7 @@ function providerEvents(acquire: Awaited>[ it('files a continuation superseded by a replayed message, lists it and says so in the chat', async () => { const { host, acquire, dispatch, root } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const events = providerEvents(acquire) // The replay lands after the reattach and just before the continuation is accepted, which is // where a send asks whether its offer still stands. @@ -64,18 +65,17 @@ it('files a continuation superseded by a replayed message, lists it and says so expect(result.failed).toMatchObject([failure]) expect(await host.restartResume.listFailures()).toMatchObject([failure]) expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toHaveLength(1) - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, tone: 'error' }) - host.release(SESSION, 'pane') }) // Nothing was attempted and nothing is owed: the user moved on between listing and acting. it('files nothing for a chat the user moved on in before its attempt, and spends the offer', async () => { const { host, acquire, root } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const events = providerEvents(acquire) const admit = StructuredAgentSessionResumeAdmission.prototype.run vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run').mockImplementationOnce( @@ -96,28 +96,26 @@ it('files nothing for a chat the user moved on in before its attempt, and spends const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) - expect(statusNotes(host)).toEqual([]) - host.release(SESSION, 'pane') + expect(await statusNotes(host)).toEqual([]) }) -it.each(['resume', 'continueAfterRestart'] as const)( - 'says so in the chat when the reattach itself fails (%s)', - async (action) => { - const { host, acquire } = await interruptedRestart() - await host.restartResume.list() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - - await host.restartResume[action]([SESSION], 'modal') - - expect(await host.restartResume.listFailures()).toMatchObject([ - { sessionId: SESSION, outcome: 'refused' } - ]) - // The fix depends on why it failed, which the dialog explains; "send a message" would not work. - expect(statusNotes(host)).toEqual([ - { text: AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, tone: 'error' } - ]) - } -) +// The continuation is accepted and its agent then fails to start: the message is rejected with the +// cause, the failure is filed, and the chat says the agent did not carry on. +it('says so in the chat when the agent cannot start for the continuation', async () => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, outcome: 'refused' } + ]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) +}) /** A continuation the provider accepted whose settlement could not be written: filed unconfirmed. */ async function unconfirmedContinuation() { @@ -125,7 +123,7 @@ async function unconfirmedContinuation() { const { host, store } = state vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const settle = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { if (input.outcome.status === 'succeeded') { @@ -135,11 +133,11 @@ async function unconfirmedContinuation() { }) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - const continuation = host.journalSnapshot(SESSION).submissions.at(-1) + const continuation = (await host.journalSnapshot(SESSION)).submissions.at(-1) const providerItemId = continuation?.providerItemId if (!continuation || !providerItemId) { throw new Error('missing accepted continuation') @@ -172,7 +170,6 @@ it.each(['submission key', 'provider key'] as const)( await vi.waitFor(async () => { expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) - host.release(SESSION, 'pane') } ) @@ -187,7 +184,6 @@ it('keeps an unconfirmed failure while the newest turn is not the continuation await host.flushStreamedEvents(SESSION) expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - host.release(SESSION, 'pane') }) // Nothing journaled the continuation, so the newest user message is still the interrupted one and @@ -196,7 +192,7 @@ it('keeps an unconfirmed failure whose continuation was never journaled while th const { host, acquire, store, marker } = await interruptedRestart('submission', false) vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const events = providerEvents(acquire) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, @@ -207,14 +203,14 @@ it('keeps an unconfirmed failure whose continuation was never journaled while th vi.spyOn(store, 'admitMutationOperation').mockRejectedValueOnce(new Error('store unavailable')) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - const submissions = host.journalSnapshot(SESSION).submissions + const submissions = (await host.journalSnapshot(SESSION)).submissions const original = submissions[0]?.providerItemId if (submissions.length !== 1 || !original) { throw new Error('expected only the original submission, accepted by the provider') } - expect(latestStructuredAgentSessionUserItem(host.journalSnapshot(SESSION).items)?.itemId).toBe( - marker?.latestUserItemId - ) + expect( + latestStructuredAgentSessionUserItem((await host.journalSnapshot(SESSION)).items)?.itemId + ).toBe(marker?.latestUserItemId) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, @@ -224,5 +220,4 @@ it('keeps an unconfirmed failure whose continuation was never journaled while th await host.flushStreamedEvents(SESSION) expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - host.release(SESSION, 'pane') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 1d7f0033a154..de3102af23ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -24,10 +24,22 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, + hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' -export const GRACE = 15_000 +/** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a + * running child before its next step. Nothing else starts one ahead of a send. */ +export async function startAgent(state: { + host: StructuredAgentSessionHost + store: AgentSessionRecordStore +}): Promise { + const result = await state.host.attach( + CALLER, + hostTestAttachParams(state.store.getRecord(SESSION)?.lease.runtimeFence ?? null) + ) + expect(result.ok).toBe(true) +} export async function interruptedRestart( work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', @@ -118,7 +130,6 @@ export async function interruptedRestart( mintSpawnToken: () => 'spawn-next', probeOwner: async () => ({ outcome: 'pid-absent' }), recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), - releaseGraceMs: GRACE, now: () => NOW }) replaceHostTestState({ store, host }) @@ -132,21 +143,20 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, closeSession, marker } } -export function statusNotes(host: StructuredAgentSessionHost) { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] - ) +export async function statusNotes(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] + ) } /** A reattach that succeeds and a continuation the host refuses: a message from another client * lands while the continuation is being recorded. `userAnswers` has the user reply in the chat * just before or after its own attempt, while the rest of a batch would still be running. */ export async function supersededRefusal(userAnswers?: 'before' | 'after') { - const { host, acquire, dispatch, root } = await interruptedRestart() + const { host, store, acquire, dispatch, root } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') + // Started ahead of the continuation, so a newer message can land from the provider. + await startAgent({ host, store }) const events = acquire.mock.calls[0]?.[0].events if (!events) { throw new Error('missing resumed provider event sink') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts index 912594a1737b..2d6f31bc223d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts @@ -5,7 +5,10 @@ import { expect, it } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_RESTART_CONTINUATION_MESSAGE } from '../../../shared/agent-session-restart-continuation' import { restartContinuationBody } from './structured-agent-session-restart-continuation' -import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { + interruptedRestart, + startAgent +} from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -30,7 +33,7 @@ it.each(['turn', 'submission'] as const)( async (work) => { const state = await interruptedRestart(work, false) const { host, dispatch } = state - await host.hold(SESSION, 'pane') + await startAgent(state) const events = resumedEvents(state) const notice = { provider: 'codex', threadId: THREAD, turnId: 'notice-turn' } as const events.appendItem( @@ -56,7 +59,6 @@ it.each(['turn', 'submission'] as const)( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') } ) @@ -64,15 +66,31 @@ it.each(['turn', 'submission'] as const)( // the body depends on the marker alone, so the ledger fingerprint stays the offer's. it('sends the same continuation body on a retry after the provider restates its rows', async () => { const state = await interruptedRestart('children') - const { host, acquire, dispatch, marker } = state + const { host, root, dispatch, marker } = state if (!marker) { throw new Error('missing interrupted restart marker') } - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - await host.restartResume.continueAfterRestart([SESSION], 'modal') + // An earlier action refused before any continuation was accepted. + const capsule = new AgentSessionRecoveryCapsule(root) + expect(await host.restartResume.list()).toHaveLength(1) + await capsule.beginResume([SESSION], 'earlier-action', NOW) + await capsule.failResume( + 'earlier-action', + [ + { + sessionId: SESSION, + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_conflict', + latestPrompt: '', + latestUserItemId: marker.latestUserItemId + } + ], + NOW + ) expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: true }]) - await host.hold(SESSION, 'pane') + await startAgent(state) resumedEvents(state).appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, { @@ -93,7 +111,6 @@ it('sends the same continuation body on a retry after the provider restates its expect(retried.continued).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) expect(dispatch.mock.calls[0]?.[0].body).toEqual(restartContinuationBody(marker)) - host.release(SESSION, 'pane') }) // A marker from a build that recorded only a working lead: no snapshot, so no activity to name, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index 69f6f661c370..e00cc1c9a918 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -3,8 +3,9 @@ import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import { rename, writeFile, rm } from 'node:fs/promises' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -19,7 +20,6 @@ it('deletes the offer once the user sends their own message in that chat', async const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.list(NOW)).toHaveLength(1) - await host.hold(SESSION, 'pane') dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Never mind, do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) @@ -28,20 +28,28 @@ it('deletes the offer once the user sends their own message in that chat', async await vi.waitFor(async () => { expect(await capsule.list(NOW)).toEqual([]) }) - host.release(SESSION, 'pane') }) // A journal this host cannot read says nothing about the user moving on, so it must not end the offer. it('keeps the offer when the chat cannot be read on this host', async () => { - const { host, root } = await interruptedRestart('submission') + const { host, root, store } = await interruptedRestart('submission') const capsule = new AgentSessionRecoveryCapsule(root) - const restoring = vi - .spyOn(StructuredAgentSessionReadableRestorer.prototype, 'restoreOne') - .mockRejectedValue(new Error('journal unreadable')) + const location = store.getRecord(SESSION)?.location + if (!location) { + throw new Error('missing session record') + } + // A file where the journal directory belongs: this host cannot open the conversation at all. + const journalDir = journalDirectoryFor(root, { + workspaceId: location.workspaceId, + sessionId: SESSION + }) + await rename(journalDir, `${journalDir}.aside`) + await writeFile(journalDir, 'not a journal') try { expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) } finally { - restoring.mockRestore() + await rm(journalDir) + await rename(`${journalDir}.aside`, journalDir) } expect(await capsule.list(NOW)).toHaveLength(1) expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index ca5ba68dee0e..9c163c0378fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -16,8 +16,8 @@ import { } from '../../../shared/agent-session-restart-continuation' import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { - GRACE, interruptedRestart, + startAgent, statusNotes, supersededRefusal } from './structured-agent-session-restart-interruption-test-harness' @@ -40,7 +40,7 @@ it('publishes continuation attribution to the subscribed chat without another pr const { host } = await interruptedRestart() await host.restartResume.list() const emit = vi.fn() - const unsubscribe = host.subscribe({ id: 'pane', sessionId: SESSION, emit }) + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit }) try { expect( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued @@ -81,22 +81,20 @@ it.each(['turn', 'submission'] as const)( async (work) => { const { host, dispatch } = await interruptedRestart(work, false) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Stop the old task and do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(work === 'turn' ? 1 : 2) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(work === 'turn' ? 1 : 2) } ) it('preserves the offer when the original submission receives its provider echo', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission', false) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const events = acquire.mock.calls[0]?.[0].events if (!events) { throw new Error('missing resumed provider event sink') @@ -106,14 +104,12 @@ it('preserves the offer when the original submission receives its provider echo' hostTestMessage('Perform the original task') ) await host.flushStreamedEvents(SESSION) - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') expect( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) }) // Teardown judged the chat working, and only the user moving on withdraws that. A send the @@ -123,14 +119,13 @@ it('still continues a chat whose last send acquisition proves was never delivere const { host, acquire, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(host.journalSnapshot(SESSION).submissions[0]).toMatchObject({ + expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ dispatchState: 'rejected', reason: 'not_delivered' }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(host.isHeld(SESSION)).toBe(false) }) /** Holds the continuation at send admission while `during` runs, then lets it proceed. */ @@ -140,7 +135,7 @@ async function continueAcross( ) { const { host, store } = state expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const events = resumedEvents(state) if (!events) { throw new Error('missing resumed provider event sink') @@ -179,16 +174,14 @@ it('refuses at send admission once the user has sent a newer message', async () ]) expect(dispatch).not.toHaveBeenCalled() // Refused where it would have been accepted, so the chat records no continuation at all. - expect(host.journalSnapshot(SESSION).submissions).toEqual([]) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) + expect((await host.journalSnapshot(SESSION)).submissions).toEqual([]) }) // Claude opens a turn of its own on reattach to say the last session did not finish. Resume is // pressed while that turn still runs: the continuation is sent and the provider queues it. it('sends the continuation while a turn the provider opened is still running', async () => { const state = await interruptedRestart() - const { host, dispatch } = state + const { dispatch } = state const result = await continueAcross(state, (events) => events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'notice-turn', ordinal: 1 }, @@ -197,14 +190,13 @@ it('sends the continuation while a turn the provider opened is still running', a ) expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) // The send the user made just before quitting, after an earlier exchange had finished. Neither // that finished turn nor the provider completing the send's own turn after the restart withdraws it. it('offers and continues a send made after an earlier completed turn', async () => { const state = await interruptedRestart('send-after-reply', false) - const { host, dispatch, marker } = state + const { dispatch, marker } = state expect(marker?.work.kind).toBe('submission') const result = await continueAcross(state, (events) => events.appendItem( @@ -214,7 +206,6 @@ it('offers and continues a send made after an earlier completed turn', async () ) expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) it.each([false, true])( @@ -222,7 +213,7 @@ it.each([false, true])( async (settlementFails) => { const { host, store, acquire, dispatch } = await interruptedRestart('submission', false) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const events = acquire.mock.calls[0]?.[0].events if (!events) { throw new Error('missing resumed provider event sink') @@ -259,9 +250,7 @@ it.each([false, true])( ]) expect(dispatch).not.toHaveBeenCalled() // Refused before acceptance: only the interrupted send is in the journal. - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) // The offer is spent, but the refusal is kept as a durable failure: a retry finds it, and the // superseded chat is still not eligible, so nothing runs and the record stays for the user. expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toMatchObject({ @@ -287,7 +276,7 @@ it.each(['completed', 'approval', 'question'] as const)( async (event) => { const { host, acquire, dispatch } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const events = acquire.mock.calls[0]?.[0].events if (!events) { throw new Error('missing resumed provider event sink') @@ -311,7 +300,6 @@ it.each(['completed', 'approval', 'question'] as const)( expect(dispatch).toHaveBeenCalledTimes(1) } finally { writing.mockRestore() - host.release(SESSION, 'pane') } } ) @@ -331,7 +319,7 @@ it('replays the same logical continuation through the durable send ledger', asyn restartContinuationEnvelope(SESSION, fence, marker) ) expect(replay).toMatchObject({ ok: true, replayed: true }) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) expect(dispatch).toHaveBeenCalledTimes(1) }) @@ -341,7 +329,7 @@ it.each([false, true])( const { host, store, dispatch } = await interruptedRestart() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) const settle = store.recordOperationOutcome.bind(store) let dispatched = false vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { @@ -357,17 +345,15 @@ it.each([false, true])( const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) // Filed as unconfirmed, with a warning in the chat; the continuation's own message is part of // the filed state, so it does not retire the record it caused. expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) await host.restartResume.continueAfterRestart([SESSION], 'retry') expect(dispatch).toHaveBeenCalledTimes(1) expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) @@ -378,16 +364,20 @@ it.each([false, true])( } ) -it('releases a failed acquisition and leaves the offer retryable', async () => { +// The continuation is accepted, then its start fails: the message is rejected with the cause and +// the failure is filed, and nothing is stopped because nothing started. +it('rejects the continuation when its start fails, and files the refusal', async () => { const { host, acquire, dispatch, closeSession } = await interruptedRestart() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - expect(await host.restartResume.resume([SESSION], 'modal')).toMatchObject([ - { outcome: 'refused' } + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.resumed).toMatchObject([{ outcome: 'refused' }]) + expect(result.continued).toMatchObject([{ outcome: 'refused' }]) + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused' }]) + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { dispatchState: 'rejected' } ]) - expect(host.isHeld(SESSION)).toBe(false) - await host.restartResume.continueAfterRestart([SESSION], 'retry') - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) + expect(acquire).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() expect(closeSession).not.toHaveBeenCalled() }) @@ -397,7 +387,7 @@ it.each([false, true])( const { host, root, acquire, dispatch } = await interruptedRestart() if (alreadyLive) { expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) } const results = await Promise.all([ host.restartResume.continueAfterRestart([SESSION], 'window-one'), @@ -411,72 +401,44 @@ it.each([false, true])( ).toHaveLength(1) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) await host.restartResume.continueAfterRestart([SESSION], 'later-click') expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - } -) - -it.each([false, true])( - 'releases reconnect acquisition to idle eviction (pane: %s)', - async (pane) => { - const { host, acquire, dispatch, closeSession } = await interruptedRestart() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - expect(await host.restartResume.resume([SESSION], 'modal')).toMatchObject([ - { outcome: 'resumed' } - ]) - expect(host.isHeld(SESSION)).toBe(false) - if (pane) { - await host.hold(SESSION, 'pane') - await vi.advanceTimersByTimeAsync(GRACE * 2) - expect(closeSession).not.toHaveBeenCalled() - host.release(SESSION, 'pane') - } - await vi.advanceTimersByTimeAsync(GRACE) - await vi.waitFor(() => expect(closeSession).toHaveBeenCalledTimes(1)) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).not.toHaveBeenCalled() } ) -it('retains acquisition through slow continuation settlement, then releases it', async () => { - const { host, dispatch, closeSession } = await interruptedRestart() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) +// The batch counts a chat done once its agent took the continuation; the provider's slow answer is +// awaited after, and decides the outcome. +it('waits out a slow provider answer before reporting the continuation', async () => { + const { host, dispatch } = await interruptedRestart() const settlement = Promise.withResolvers>>() const dispatched = Promise.withResolvers() dispatch.mockImplementationOnce(() => { dispatched.resolve() return settlement.promise }) - const continuing = host.restartResume.continueAfterRestart([SESSION], 'modal') + let settled = false + const continuing = host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => (settled = true)) await dispatched.promise - await vi.advanceTimersByTimeAsync(GRACE * 2) - expect(host.isHeld(SESSION)).toBe(true) - expect(closeSession).not.toHaveBeenCalled() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBe(false) settlement.resolve({ state: 'rejected', reason: 'provider refused' }) expect((await continuing).continued).toMatchObject([{ outcome: 'refused' }]) - expect(host.isHeld(SESSION)).toBe(false) - await vi.advanceTimersByTimeAsync(GRACE) - await vi.waitFor(() => expect(closeSession).toHaveBeenCalledTimes(1)) expect(dispatch).toHaveBeenCalledTimes(1) }) -// Opening the chat is inspection only. The explicit restart action is what removes the durable -// offer, so ordinary pane lifecycle must not make this status disappear. +// Opening the chat is inspection only: it starts nothing, and the explicit restart action is what +// removes the durable offer, so reading the chat must not make this status disappear. it('keeps offering a chat after the user opens it', async () => { - const { host, root, store } = await interruptedRestart() + const { host, root, acquire } = await interruptedRestart() expect(await host.restartResume.list()).toHaveLength(1) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - await host.hold(SESSION, 'pane') - host.release(SESSION, 'pane') - await vi.advanceTimersByTimeAsync(GRACE) - // The whole eviction, not just the provider stop: the lease returns to `released` on the step - // before the last, and until it does the offer is refused for a reason that is not recovery. - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - vi.useRealTimers() + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + unsubscribe() + expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) await host.restartResume.recordMarkers() @@ -545,7 +507,7 @@ it('keeps concurrent recovery reads independent and non-destructive', async () = list.mockRestore() }) -it('fails closed on corrupt recovery storage while ordinary hold and send still work', async () => { +it('fails closed on corrupt recovery storage while an ordinary send still works', async () => { const { host, root, dispatch } = await interruptedRestart() await writeFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), '{') const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -556,7 +518,6 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still sessions: [], failed: [] }) - await host.hold(SESSION, 'pane') const body = hostTestMessage('A fresh ordinary request') expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) @@ -565,7 +526,6 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still // list; the action's read of offers and of failures; the post-action refresh of both. expect(warning).toHaveBeenCalledTimes(5) warning.mockRestore() - host.release(SESSION, 'pane') }) // The toast is gone in seconds and the offer is spent by the reattach, so without this record @@ -582,7 +542,7 @@ it('keeps a refused continuation as a durable failure that names the chat and th } expect(result).toMatchObject({ sessions: [], failed: [failure] }) // The chat itself says what happened and what to do. - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, tone: 'error' }) @@ -592,7 +552,6 @@ it('keeps a refused continuation as a durable failure that names the chat and th expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toMatchObject([ { marker: { sessionId: SESSION }, outcome: 'refused' } ]) - host.release(SESSION, 'pane') }) // The failure asked the user to continue the chat themselves; their own message is that @@ -606,7 +565,6 @@ it('retires a recorded failure once the user sends in that chat, with no send ho await vi.waitFor(async () => { expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) - host.release(SESSION, 'pane') }) // The user can reply in a chat while other chats in the same action are still being continued, @@ -619,7 +577,7 @@ it.each(['before', 'after'] as const)( userAnswers === 'before' ? { reason: 'agent_session_resume_not_eligible' } : {} ]) expect( - statusNotes(host).some( + (await statusNotes(host)).some( (note) => note.text === AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE ) ).toBe(userAnswers === 'after') @@ -627,7 +585,6 @@ it.each(['before', 'after'] as const)( await vi.waitFor(async () => { expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) - host.release(SESSION, 'pane') } ) @@ -681,7 +638,6 @@ it('dismisses one failure by name and leaves the rest of the durable records alo expect(await host.restartResume.dismiss([SESSION])).toBe(1) expect(await host.restartResume.listFailures()).toEqual([]) expect(await capsule.list(NOW)).toEqual([other]) - host.release(SESSION, 'pane') }) it('logs teardown capsule publication failure and still releases the provider', async () => { @@ -700,7 +656,9 @@ it('logs teardown capsule publication failure and still releases the provider', await mkdir(capsulePath) const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect(previous.host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(() => previous.host.journalSnapshot(SESSION)).toThrow('agent_session_ownership_unknown') + await expect(previous.host.journalSnapshot(SESSION)).rejects.toThrow( + 'agent_session_ownership_unknown' + ) expect(warning).toHaveBeenCalledWith( '[structured-agent-session] recording recovery capsule failed' ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 61e61debe14f..2d83daa69762 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -10,10 +10,7 @@ vi.mock('./structured-agent-session-read-restore', () => ({ restoreStructuredAgentSessionRead: restoreRead })) -import { - restoreOneStructuredAgentSessionRead, - restoreStructuredAgentSessionsOnRestart -} from './structured-agent-session-restart-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, @@ -99,27 +96,26 @@ describe('restart journal restoration', () => { reset: null }) - await restoreOneStructuredAgentSessionRead( - { - openDeps: NO_OPEN_DEPS, - reconcile: async () => null, - resolveRecovery: async () => { - calls.push('resolveRecovery') - }, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => { - calls.push('onReadable') - }, - retrySettlement: async (_sessionId, restoredParams) => { - calls.push( - restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement' - ) - return true - } + await restoreStructuredAgentSessionsOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + records: [{ sessionId: 'session-1' } as AgentSessionRecord], + openDeps: NO_OPEN_DEPS, + reconcile: async () => null, + resolveRecovery: async () => { + calls.push('resolveRecovery') }, - 'session-1' - ) + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => { + calls.push('onReadable') + }, + retrySettlement: async (_sessionId, restoredParams) => { + calls.push( + restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement' + ) + return true + } + }) expect(calls).toEqual(['resolveRecovery', 'onReadable', 'retrySettlement:restored-params']) }) @@ -137,18 +133,17 @@ describe('restart journal restoration', () => { reset: null }) - await restoreOneStructuredAgentSessionRead( - { - openDeps: NO_OPEN_DEPS, - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize: async (_sessionId, task) => task(), - hasSession: () => true, - onReadable: () => undefined, - retrySettlement - }, - 'session-1' - ) + await restoreStructuredAgentSessionsOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + records: [{ sessionId: 'session-1' } as AgentSessionRecord], + openDeps: NO_OPEN_DEPS, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => true, + onReadable: () => undefined, + retrySettlement + }) expect(retrySettlement).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 index 719c60f3e196..e2532483b103 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -8,8 +8,8 @@ // It does not owe a provider child. This used to resume every record whose lease was `released`, // which is the normal end state of a chat the user closed cleanly — so a // healthy profile started an app-server per session it had ever used, in parallel, at every launch, -// with no client attached and nothing on screen. A child now exists because a surface asked for the -// session (see `structured-agent-session-holds`), not because a record survived on disk. +// with no client attached and nothing on screen. A child now exists because work asked for it — a +// send, through the delivery loop — not because a record survived on disk. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' @@ -39,14 +39,8 @@ export type StructuredAgentSessionReadRestoreDeps = { retrySettlement: (sessionId: string, params: AgentSessionAttachParams) => Promise } -/** - * One session's share of the restart restore, and the whole of an on-demand one. - * - * Startup maps this over every supported record; a surface asking for a session it cannot see - * calls it for one id. The CALLER decides which records are eligible — startup filters by - * `supportsRecord` before mapping, so an on-demand caller owes the same check. - */ -export async function restoreOneStructuredAgentSessionRead( +/** One session's share of the restart restore. Startup maps this over every supported record. */ +async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, sessionId: string ): Promise { @@ -60,9 +54,8 @@ export async function restoreOneStructuredAgentSessionRead( ) } -/** The serialized half of the restore, for a caller already inside the session's serialize — a - * send replaying into a session this host has closed, which needs the journal and no child. */ -export async function restoreOneStructuredAgentSessionReadUnderSerialize( +/** The serialized half of the restore. */ +async function restoreOneStructuredAgentSessionReadUnderSerialize( input: Pick< StructuredAgentSessionReadRestoreDeps, 'openDeps' | 'hasSession' | 'onReadable' | 'retrySettlement' @@ -70,7 +63,7 @@ export async function restoreOneStructuredAgentSessionReadUnderSerialize( sessionId: string ): Promise { if (input.hasSession(sessionId)) { - // A surface that took a hold mid-restore already attached this one. + // A read or a send mid-restore already opened this one. return } const opened = await restoreStructuredAgentSessionRead(input.openDeps, 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 0fa4ee8534d8..0f01639ee281 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 @@ -34,9 +34,8 @@ import { type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' import { - continueStructuredAgentSessionAfterRestart, - noteRestartReattachFailed, restartContinuationDeps, + startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationHost, type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' @@ -47,8 +46,6 @@ type LiveSession = { journal: AgentSessionJournal; child: { fence: number } | nu export type StructuredAgentSessionRestartResumeSurfaces = { revealSession: (sessionId: string) => Promise<{ readable: boolean }> - hold: (sessionId: string, holderId: string) => Promise - release: (sessionId: string, holderId: string) => void send: (input: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem @@ -58,6 +55,10 @@ export type StructuredAgentSessionRestartResumeSurfaces = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> + awaitSendHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void now: () => number } @@ -72,10 +73,6 @@ export type StructuredAgentSessionRestartResume = { list: () => Promise /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ listFailures: () => Promise - resume: ( - sessionIds: readonly string[] | undefined, - owner: string - ) => Promise continueAfterRestart: ( sessionIds: readonly string[] | undefined, owner: string @@ -154,22 +151,20 @@ export function createStructuredAgentSessionRestartResume( ...surfaces, sessions, conversationFence: (sessionId) => - sessions.has(sessionId) + deps.store.getRecord(sessionId) ? structuredAgentSessionConversationFence(deps.store, sessionId) : null, stillResumable: (marker, options) => derive([marker], 'may-be-held', options).candidates.length === 1 } + /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at + * a time. The runner counts a chat as done once `continueOne` returns. */ const run = async ( sessionIds: readonly string[] | undefined, owner: string, - afterAcquire?: (marker: AgentSessionResumeMarker) => Promise, - settlement: Omit[2], 'candidates' | 'attempts'> = { - failureAfterResume: () => null, - failureReason: () => 'agent_session_resume_refused' - } - ): Promise => { + continueOne: (marker: AgentSessionResumeMarker) => Promise + ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. witnesses.clear() @@ -180,7 +175,7 @@ export function createStructuredAgentSessionRestartResume( retireSuperseded(derived.superseded) const eligible = derived.candidates.filter((candidate) => requested.has(candidate.sessionId)) if (eligible.length === 0) { - return [] + return null } const operationId = randomUUID() const reserved = @@ -195,10 +190,8 @@ export function createStructuredAgentSessionRestartResume( const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates const attempts = failures.attempts(markersBySession) - - let outcomes: StructuredAgentSessionResumeOutcome[] try { - outcomes = await resumeStructuredAgentSessionsFromRestart( + const outcomes = await resumeStructuredAgentSessionsFromRestart( { admission, consumeMarker: async (sessionId) => { @@ -206,26 +199,20 @@ export function createStructuredAgentSessionRestartResume( return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 }, resume: async (sessionId) => { - const holder = `restart-resume:${sessionId}` + const marker = markersBySession.get(sessionId) try { - await surfaces.hold(sessionId, holder).catch(async (error: unknown) => { - // The reattach failure is filed like any other, so the chat must say so too. - await noteRestartReattachFailed(continuationHost, sessionId) - throw error - }) - const marker = markersBySession.get(sessionId) if (marker) { - await afterAcquire?.(marker) + await continueOne(marker) } } finally { attempts.observe(sessionId) - surfaces.release(sessionId, holder) } } }, candidates, owner ) + return { operationId, outcomes, candidates, attempts } } catch (error) { if (deps.recoveryCapsule) { await enqueueRecoveryOperation(() => @@ -236,8 +223,6 @@ export function createStructuredAgentSessionRestartResume( } throw error } - await failures.settle(operationId, outcomes, { candidates, attempts, ...settlement }) - return outcomes } const continueAfterRestart = async ( @@ -250,34 +235,45 @@ export function createStructuredAgentSessionRestartResume( failed?: StructuredAgentSessionResumeFailure[] }> => { const continued: StructuredAgentSessionContinuationOutcome[] = [] - const continuationFor = (sessionId: string) => - continued.find((outcome) => outcome.sessionId === sessionId) - const resumed = await run( - sessionIds, - owner, - async (marker) => { - continued.push( - await continueStructuredAgentSessionAfterRestart( - restartContinuationDeps(continuationHost, marker), - marker.sessionId, - marker - ) - ) - }, - { + const verdicts: Promise[] = [] + // A chat holds its slot until its agent took the continuation or its start failed, so a batch + // never starts more agents at once than the runner allows; the provider's answer comes after. + const action = await run(sessionIds, owner, async (marker) => { + const started = await startStructuredAgentSessionContinuation( + restartContinuationDeps(continuationHost, marker), + marker.sessionId, + marker + ) + if ('done' in started) { + continued.push(started.done) + if (started.done.outcome === 'refused') { + throw new Error(started.done.reason ?? 'agent_session_continuation_refused') + } + return + } + verdicts.push(started.verdict().then((outcome) => void continued.push(outcome))) + }) + await Promise.all(verdicts) + const resumed = action?.outcomes ?? [] + if (action) { + await failures.settle(action.operationId, resumed, { + candidates: action.candidates, + attempts: action.attempts, failureAfterResume: (sessionId) => { - const outcome = continuationFor(sessionId) - // Reattached but never asked to continue: nothing confirms the agent carried on. - return outcome ? continuationFailureOutcome(outcome.outcome) : 'unconfirmed' + const outcome = continued.find((entry) => entry.sessionId === sessionId) + return outcome ? continuationFailureOutcome(outcome.outcome) : null }, failureReason: (sessionId) => { - const outcome = continuationFor(sessionId) + const outcome = continued.find((entry) => entry.sessionId === sessionId) return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' } - } - ) + }) + } for (const outcome of resumed) { - if (outcome.outcome !== 'resumed') { + if ( + outcome.outcome !== 'resumed' && + !continued.some((entry) => entry.sessionId === outcome.sessionId) + ) { continued.push({ sessionId: outcome.sessionId, outcome: 'refused', @@ -311,7 +307,6 @@ export function createStructuredAgentSessionRestartResume( // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), - resume: (sessionIds, owner) => run(sessionIds, owner), continueAfterRestart } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 33a07e80335c..05b3ef964508 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -1,13 +1,12 @@ // Delivering the explicit restart action: the one path that turns a resumable candidate back into -// a live agent. +// a working agent. // // The manual "Resume" button and the automatic setting both land here, so the two can never drift // into different eligibility or different double-fire protection. // -// Resume itself acquires a provider child, not a new send. The first resume-capable hold on a -// childless session re-acquires the provider's own conversation — Claude's `resume` by session id, -// Codex's thread id — which is native continuation. Nothing re-sends the user's prompt: that is -// what makes an agent redo work it already finished. +// A resume is the continuation send. Its delivery starts the provider on its own conversation — +// Claude's `resume` by session id, Codex's thread id — which is native continuation. Nothing +// re-sends the user's prompt: that is what makes an agent redo work it already finished. import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' @@ -52,7 +51,7 @@ function resumeAdmissionOwner(error: unknown): string | null { * One resume per session at a time, whoever is asking. * * Two surfaces can reach for the same chat at once — the banner's "Resume all" and a user clicking - * one row — and both would otherwise take a hold, race the acquisition, and leave the loser's + * one row — and both would otherwise send the continuation twice, and leave the loser's * refusal looking like a real failure. The second caller is told who holds it instead. */ export class StructuredAgentSessionResumeAdmission { @@ -80,7 +79,7 @@ export type StructuredAgentSessionResumeRunnerDeps = { admission: StructuredAgentSessionResumeAdmission /** Validates this action's durable reservation. False means the candidate is no longer eligible. */ consumeMarker: (sessionId: string) => Promise - /** Acquires the provider child for the reserved session. */ + /** Continues the reserved session; resolves once its agent took the message or refused it. */ resume: (sessionId: string) => Promise concurrency?: number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 66461a0c8739..ce244fefe51f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -23,8 +23,6 @@ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = * dependency, and nothing outside this list is reachable from here. */ type RestartResumeHostBindings = { revealSession: (sessionId: string) => Promise<{ readable: boolean }> - hold: (sessionId: string, holderId: string) => Promise - release: (sessionId: string, holderId: string) => void send: ( caller: { callerKey: string }, params: { @@ -47,14 +45,19 @@ export function structuredAgentSessionRestartResumeSurfaces( ): StructuredAgentSessionRestartResumeSurfaces { return { revealSession: host.revealSession, - hold: host.hold, - release: host.release, send: (params) => host.send({ callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, params), // Accepted like any send, so its verdict is its delivery, however long the start takes; the // wait ends when the submission settles or the session closes. awaitSendSettlement: (sessionId, clientMessageId) => host.waitForSendSettlement(sessionId, clientMessageId, { budgetMs: MAX_TIMER_DELAY_MS }), + // How long a restart batch holds a chat's slot: until the agent took the message or its start + // failed. Undefined — the session closed, or too many waited — frees the slot too. + awaitSendHandedOver: (sessionId, clientMessageId) => + host.waitForSendSettlement(sessionId, clientMessageId, { + until: 'handed-over', + budgetMs: MAX_TIMER_DELAY_MS + }), onNoteFailed: () => console.warn('[structured-agent-session] restart continuation attribution failed'), now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index cc40d4831875..bf740ac405b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -30,7 +30,7 @@ it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( ) expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain(failure.message) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(() => host.journalSnapshot(SESSION)).toThrow('agent_session_ownership_unknown') + await expect(host.journalSnapshot(SESSION)).rejects.toThrow('agent_session_ownership_unknown') } finally { operation.mockRestore() warning.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index c8f75823708c..49a116ec7f7a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -3,8 +3,8 @@ * * The reveal path is the only way an Agent Session History row reaches a chat whose tab this * process never published — a chat closed cleanly, or one this process has not opened since - * launch. It is exercised here through the readable restorer rather than the whole host, because - * what it must get right is which records it accepts and what it does when the journal is gone. + * launch. It opens the conversation through the host's accessor and starts no agent; what it must + * get right is which records it accepts and what it answers when the journal cannot be opened. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,8 +13,6 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' -import * as readRestore from './structured-agent-session-read-restore' import * as providerSupport from './structured-agent-session-provider-support' import { revealStructuredAgentSession } from './structured-agent-session-reveal' @@ -35,123 +33,10 @@ function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessio } } -function harness( - records: AgentSessionRecord[], - options: { supports?: (record: AgentSessionRecord) => boolean } = {} -) { - const live = new Map() - const serializedIds: string[] = [] - const serialize = (sessionId: string, task: () => Promise): Promise => { - serializedIds.push(sessionId) - return task() - } - const restorer = new StructuredAgentSessionReadableRestorer({ - openDeps: { - store: { - getRecord: (sessionId: string) => records.find((r) => r.sessionId === sessionId) ?? null, - listRecords: () => records - }, - journalRoot: '/journals', - adapter: {} - }, - supportsRecord: options.supports ?? (() => true), - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize, - hasSession: (sessionId) => live.has(sessionId), - onReadable: (sessionId, restored) => { - live.set(sessionId, restored) - }, - retrySettlement: async () => true - }) - return { restorer, live, serializedIds } -} - -const readable = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restorer only indexes the session it is handed; no member of it is read here. - session: { journal: {}, params: {}, fence: 1 } as never, - reset: null -} - afterEach(() => { vi.restoreAllMocks() }) -describe('revealing one structured session on demand', () => { - beforeEach(() => { - vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(readable) - }) - - it.each(['claude', 'codex'] as const)('restores a persisted %s chat', async (provider) => { - const sessionId = `session-${provider}` - const { restorer, live } = harness([recordFor(provider, sessionId)]) - - await expect(restorer.restoreOne(sessionId)).resolves.toBe(true) - expect(live.has(sessionId)).toBe(true) - }) - - it('does not need the startup sweep to have run, or run it', async () => { - // The whole point: `restore()` is latched to once per process, and a surface asking later must - // not be answered from that latch — nor trip it, which would skip every other record. - const records = [recordFor('codex', 'session-asked'), recordFor('claude', 'session-untouched')] - const { restorer, live } = harness(records) - - await restorer.restoreOne('session-asked') - - expect(live.has('session-asked')).toBe(true) - expect(live.has('session-untouched')).toBe(false) - }) - - it('serializes against the session it restores', async () => { - const { restorer, serializedIds } = harness([recordFor('claude', 'session-serialized')]) - - await restorer.restoreOne('session-serialized') - - // Ordering against close/eviction is the task queue's job, so the restore must be inside it. - expect(serializedIds).toEqual(['session-serialized']) - }) - - it('returns the live session instead of restoring over it', async () => { - const { restorer, live } = harness([recordFor('codex', 'session-live')]) - live.set('session-live', readable) - - await expect(restorer.restoreOne('session-live')).resolves.toBe(true) - expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() - }) - - it('refuses a record no adapter supports', async () => { - const { restorer } = harness([recordFor('codex', 'session-unsupported')], { - supports: () => false - }) - - await expect(restorer.restoreOne('session-unsupported')).resolves.toBe(false) - expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() - }) - - it('refuses a session this host holds no record for', async () => { - const { restorer } = harness([]) - - await expect(restorer.restoreOne('session-absent')).resolves.toBe(false) - }) -}) - -describe('a record whose journal cannot be read', () => { - it('reports not-readable without throwing, for either provider', async () => { - // A chat whose journal predates the SQLite store restores to nothing here. That is not a - // refusal: attach still recovers it, so the caller publishes the tab and lets the pane's hold - // finish the job. Throwing, or reporting success, would both be wrong. - vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(null) - const { restorer, live } = harness([ - recordFor('claude', 'session-no-journal-claude'), - recordFor('codex', 'session-no-journal-codex') - ]) - - await expect(restorer.restoreOne('session-no-journal-claude')).resolves.toBe(false) - await expect(restorer.restoreOne('session-no-journal-codex')).resolves.toBe(false) - expect(live.size).toBe(0) - }) -}) - describe('the host answer a client acts on', () => { beforeEach(() => { // Eligibility is the router's call; these cases are about what the answer carries. @@ -163,39 +48,45 @@ describe('the host answer a client acts on', () => { return { ...base, location: { ...base.location, workspaceId } } } - it("answers with the record's own workspace and provider, never a caller's", async () => { - // The security property: a client sends only a session id, so the tab cannot be aimed at - // another workspace by asking for one. - const stored = record('claude', 'workspace-from-record') - - await expect( - revealStructuredAgentSession( - { store: { getRecord: () => stored } as never, adapter: {} as never }, - 'session-answered', - () => true, - async () => true - ) - ).resolves.toEqual({ - sessionId: 'session-answered', - workspaceId: 'workspace-from-record', - agent: 'claude', - readable: true - }) - }) - - it('refuses a session this host holds no record for', async () => { + it.each(['claude', 'codex'] as const)( + "answers a %s chat with the record's own workspace and provider, never a caller's", + async (provider) => { + // The security property: a client sends only a session id, so the tab cannot be aimed at + // another workspace by asking for one. + const stored = record(provider, 'workspace-from-record') + const open = vi.fn(async () => undefined) + + await expect( + revealStructuredAgentSession( + { store: { getRecord: () => stored } as never, adapter: {} as never }, + 'session-answered', + open + ) + ).resolves.toEqual({ + sessionId: 'session-answered', + workspaceId: 'workspace-from-record', + agent: provider, + readable: true + }) + expect(open).toHaveBeenCalledExactlyOnceWith('session-answered') + } + ) + + it('refuses a session this host holds no record for, opening nothing', async () => { + const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( { store: { getRecord: () => null } as never, adapter: {} as never }, 'session-absent', - () => false, - async () => false + open ) ).rejects.toThrow('agent_session_identity_required') + expect(open).not.toHaveBeenCalled() }) it('refuses a record no adapter of this host supports', async () => { vi.mocked(providerSupport.adapterSupportsRecord).mockReturnValue(false) + const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( @@ -204,14 +95,14 @@ describe('the host answer a client acts on', () => { adapter: {} as never }, 'session-answered', - () => false, - async () => false + open ) ).rejects.toThrow('structured_agent_session_unsupported') + expect(open).not.toHaveBeenCalled() }) - it('answers not-readable without refusing when the journal could not be restored', async () => { - // The tab is still worth publishing: attach recovers what read restore cannot. + it('answers not-readable without refusing when the journal could not be opened', async () => { + // The tab is still worth publishing: the chat shows the failure and keeps retrying the read. await expect( revealStructuredAgentSession( { @@ -219,26 +110,10 @@ describe('the host answer a client acts on', () => { adapter: {} as never }, 'session-answered', - () => false, - async () => false + async () => { + throw new Error('journal unreadable') + } ) ).resolves.toMatchObject({ readable: false, agent: 'codex' }) }) - - it('does not restore over a session that is already live', async () => { - const restoreReadable = vi.fn(async () => true) - - await expect( - revealStructuredAgentSession( - { - store: { getRecord: () => record('codex', 'workspace-1') } as never, - adapter: {} as never - }, - 'session-answered', - () => true, - restoreReadable - ) - ).resolves.toMatchObject({ readable: true }) - expect(restoreReadable).not.toHaveBeenCalled() - }) }) 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 8bf3bc2242c9..904be1e69777 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 @@ -6,10 +6,9 @@ // launch — is reachable in Agent Session History by id and by nothing else. This is the lookup that // turns that id back into something a client can publish. // -// It is deliberately the whole of what reveal does on the host. It takes no hold: a provider child -// exists because a surface asked for one, and the chat pane asks when it binds. And a journal it -// cannot read is not a refusal — a chat whose journal predates the SQLite store restores to nothing -// here, yet attach still recovers it, so the tab is worth publishing either way. +// It is deliberately the whole of what reveal does on the host. It starts no provider child: only +// a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a +// Retry, so the tab is worth publishing either way. import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' @@ -21,12 +20,11 @@ import type { } from './structured-agent-session-host-types' import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -/** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */ +/** Throws its refusal as the code itself. */ export async function revealStructuredAgentSession( deps: Pick, sessionId: string, - hasSession: (sessionId: string) => boolean, - restoreReadable: (sessionId: string) => Promise + openConversation: (sessionId: string) => Promise ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { @@ -36,9 +34,12 @@ export async function revealStructuredAgentSession( throw new Error('structured_agent_session_unsupported') } // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a - // contested or reconciling chat still reveals and the hold that follows adjudicates it. Refusing + // contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing // here would hide the one view of a session a user needs when its ownership is in doubt. - const readable = hasSession(sessionId) || (await restoreReadable(sessionId)) + const readable = await openConversation(sessionId).then( + () => true, + () => false + ) return { sessionId, // From the record, never from a caller: a client that knows only a session id must not be able @@ -49,12 +50,7 @@ export async function revealStructuredAgentSession( } } -/** - * The host's whole readable-restore surface: the startup sweep and the on-demand reveal. - * - * Bundled the way the lifetime collaborators are, because the two share the restorer - * and differ only in who is asking — startup, once, for everything; a surface, later, for one. - */ +/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, sessions: Map, @@ -65,7 +61,6 @@ export function createStructuredAgentSessionHostRestore( > ): { restoreReadableSessions: (sessionIds?: readonly string[]) => Promise - revealSession: (sessionId: string) => Promise } { const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, @@ -82,10 +77,6 @@ export function createStructuredAgentSessionHostRestore( }) const gate = new StructuredAgentSessionRestartRestoreGate() return { - restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)), - revealSession: (sessionId) => - revealStructuredAgentSession(deps, sessionId, wiring.hasSession, (id) => - restorer.restoreOne(id) - ) + restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts new file mode 100644 index 000000000000..a596cc5c9edb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -0,0 +1,225 @@ +// A Codex rewind whose outcome only the provider can prove, found on a chat at rest: nothing on +// screen will start the agent that settles it, so the next send does, and its message is kept. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const caller = { callerKey: 'desktop' } +const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } + +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink +let acquires = 0 +const rewind = vi.fn>() +const recoverRewind = vi.fn>() +const rewindSupport = vi.fn>() +const dispatch = vi.fn() + +function adapter(): StructuredAgentSessionAdapter { + return { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: { provider: 'codex', threadId: THREAD } + } + } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true + } +} + +function openHost(): StructuredAgentSessionHost { + return new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: directory, + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + probeOwner: async () => ({ outcome: 'exit-observed' }), + idleSweep: { intervalMs: 3_600_000 } + }) +} + +beforeEach(async () => { + resetHostTestOperationIds() + acquires = 0 + rewind.mockReset() + recoverRewind.mockReset().mockResolvedValue({ + ok: true, + items: [{ identity: KEPT, body: hostTestMessage('verified history') }] + }) + rewindSupport.mockReset().mockReturnValue({ supported: true }) + dispatch.mockReset().mockImplementation(async (input): Promise => ({ + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: input.clientMessageId, + ordinal: 1 + } + })) + directory = await mkdtemp(join(tmpdir(), 'orca-rewind-rest-')) + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +function fence(): number { + return store.getRecord(SESSION)!.lease.runtimeFence +} + +function rewindParams(itemId: string, expectedEpoch: string) { + return { + itemId, + expectedEpoch, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: SESSION, + fields: { itemId, expectedEpoch } + }) + } + } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { + body, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + } +} + +/** A chat whose rewind the provider applied, whose Orca side never finished, reopened at rest. */ +async function interruptedRewindAtRest(): Promise { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const drop = { ...KEPT, turnId: 'drop' } + sink.appendItem(KEPT, hostTestMessage('verified history')) + sink.appendItem(drop, hostTestMessage('to be rewound')) + await host.flushStreamedEvents(SESSION) + rewind.mockImplementation(async (input) => { + await input.onReverted?.() + throw new Error('history unavailable') + }) + const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await expect(host.rewind(caller, rewindParams(agentJournalItemKey(drop), epoch))).rejects.toThrow( + 'history unavailable' + ) + expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() +} + +describe('an interrupted Codex rewind on a chat at rest (R16)', () => { + it('is settled by the start a send makes, and the message is delivered after it', async () => { + await interruptedRewindAtRest() + expect(host.hasSession(SESSION)).toBe(false) + const before = acquires + + const sent = await host.send(caller, sendParams('after the rewind')) + expect(sent).toMatchObject({ ok: true }) + expect(acquires - before).toBe(1) + expect(recoverRewind).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + const snapshot = await host.journalSnapshot(SESSION) + const texts = snapshot.items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + // The recovered history, then the new message — nothing the rewind dropped, nothing lost. + expect(texts).toEqual(['verified history', 'after the rewind']) + expect(snapshot.submissions.at(-1)?.dispatchState).toBe('accepted') + }) +}) + +describe('a rewind asked of a chat at rest (P2-23)', () => { + it('starts the agent first and answers with what the provider says', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + sink.appendItem(KEPT, hostTestMessage('kept')) + await host.flushStreamedEvents(SESSION) + const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() + const before = acquires + rewindSupport.mockReturnValue({ supported: false, reason: 'history-not-paginated' }) + + const result = await host.rewind(caller, rewindParams(agentJournalItemKey(KEPT), epoch)) + expect(acquires - before).toBe(1) + expect(result).toMatchObject({ ok: false, refusal: { rewindReason: 'history-not-paginated' } }) + expect(rewind).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 32b05b080774..a8ded60e6ffb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -140,11 +140,11 @@ async function seed(acceptedSubmissions = false) { }) ).toMatchObject({ ok: true }) // Accepted into the conversation first; the delivery loop hands it over after. - await vi.waitFor(() => + await vi.waitFor(async () => expect( - host - .journalSnapshot(HOST_TEST_SESSION) - .submissions.find((entry) => entry.clientMessageId === clientOperationId)?.dispatchState + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.dispatchState ).toBe('accepted') ) if (i === 1) { @@ -157,10 +157,8 @@ async function seed(acceptedSubmissions = false) { await host.flushStreamedEvents(HOST_TEST_SESSION) return selectedItemId } -function params( - itemId: string, - expectedEpoch = host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch -) { +async function params(itemId: string, epoch?: string) { + const expectedEpoch = epoch ?? (await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch return { itemId, expectedEpoch, @@ -181,14 +179,14 @@ describe('host rewind', () => { it('resolves accepted codex user submissions to provider targets', async () => { const target = await seed(true) expect(target.startsWith('orca:')).toBe(true) - expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' })) }) it('finishes a durable provider success on reattach without repeating the provider mutation', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const replace = vi .spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') .mockRejectedValueOnce(new Error('disk failed')) @@ -197,7 +195,7 @@ describe('host rewind', () => { replace.mockRestore() const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) @@ -205,13 +203,13 @@ describe('host rewind', () => { it('retries complete hydration after native acknowledgement without committing partial history', async () => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockImplementation(async (input) => { await input.onReverted?.() throw new Error('history unavailable') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('history unavailable') - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + await expect(host.rewind(caller, await params(target))).rejects.toThrow('history unavailable') + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared', providerApplied: true @@ -230,8 +228,8 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) - expect(host.journalSnapshot(HOST_TEST_SESSION).items[0]?.body).toEqual( + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items[0]?.body).toEqual( hostTestMessage('verified history') ) expect(recoverRewind).toHaveBeenCalledTimes(2) @@ -246,9 +244,9 @@ describe('host rewind', () => { finish = () => resolve({ ok: true }) }) ) - const first = host.rewind(caller, params(target)) - const second = host.rewind(caller, params(target)) - await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + const first = host.rewind(caller, await params(target)) + const second = host.rewind(caller, await params(target)) + await vi.waitFor(async () => expect(finish).toBeTypeOf('function')) finish() expect(await first).toMatchObject({ ok: true }) expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) @@ -256,11 +254,13 @@ describe('host rewind', () => { }) it('replaces the epoch with the retained prefix and replays without another provider call', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const result = await host.rewind(caller, request) expect(result).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) - expect(host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch).not.toBe(request.expectedEpoch) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe( + request.expectedEpoch + ) expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) }) @@ -270,7 +270,7 @@ describe('host rewind', () => { { provider: 'orca', clientMessageId: 'active' }, { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } } ) - expect(await host.rewind(caller, params(target))).toMatchObject({ + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: false, refusal: { rewindReason: 'busy' } }) @@ -278,11 +278,11 @@ describe('host rewind', () => { }) it('refuses stale epochs and targets from another provider', async () => { const target = await seed() - expect(await host.rewind(caller, params(target, 'old-epoch'))).toMatchObject({ + expect(await host.rewind(caller, await params(target, 'old-epoch'))).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) - expect(await host.rewind(caller, params('claude:foreign'))).toMatchObject({ + expect(await host.rewind(caller, await params('claude:foreign'))).toMatchObject({ ok: false, refusal: { rewindReason: 'invalid-target' } }) @@ -290,18 +290,18 @@ describe('host rewind', () => { }) it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => { const target = await seed() - const request = params(target) - const before = host.journalSnapshot(HOST_TEST_SESSION) + const request = await params(target) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockRejectedValue(new Error('hydration failed')) await expect(host.rewind(caller, request)).rejects.toThrow('hydration failed') - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(await host.rewind(caller, request)).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_unknown' } }) const body = hostTestMessage('new prompt') const envelope = { - ...params(target).envelope, + ...(await params(target)).envelope, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: HOST_TEST_SESSION, @@ -326,9 +326,9 @@ describe('host rewind', () => { it('clears an unapplied prepared rewind after observing the target still present', async () => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockRejectedValueOnce(new Error('read failed before revert')) - await expect(host.rewind(caller, params(target))).rejects.toThrow('read failed') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( await host.attach( @@ -336,9 +336,9 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') - expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) }) it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { @@ -392,12 +392,17 @@ describe('host rewind', () => { return { ok: true, items } }) - expect(await host.rewind(caller, params(agentJournalItemKey(message('drop'))))).toMatchObject({ + expect( + await host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).toMatchObject({ ok: true }) expect( - host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) ).toEqual([ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') }, { itemId: agentJournalItemKey(goalRow), body: goalBody }, @@ -437,9 +442,9 @@ describe('host rewind', () => { throw new Error('lost after provider revert') }) - await expect(host.rewind(caller, params(agentJournalItemKey(message('drop'))))).rejects.toThrow( - 'lost after provider revert' - ) + await expect( + host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).rejects.toThrow('lost after provider revert') recoverRewind.mockResolvedValueOnce({ ok: true, items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] @@ -452,7 +457,10 @@ describe('host rewind', () => { ).toMatchObject({ ok: true }) expect( - host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) ).toEqual([ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from recovery') }, { itemId: agentJournalItemKey(goalRow), body: goalBody } @@ -471,7 +479,7 @@ describe('host rewind', () => { await input.onReverted?.() throw new Error('lost after revert') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('lost after revert') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) expect( await host.attach( @@ -479,7 +487,7 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(2) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) @@ -487,7 +495,7 @@ describe('host rewind', () => { 'never commits a recovered prefix that omits an expected retained %s', async (missing) => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) const items = [0, 1].map((ordinal) => ({ identity: { provider: 'codex' as const, @@ -501,7 +509,7 @@ describe('host rewind', () => { await input.onPrepared?.(items) throw new Error('reply lost') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('reply lost') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('reply lost') recoverRewind.mockResolvedValueOnce({ ok: true, items: missing === 'turn' ? [] : items.slice(0, 1) @@ -522,7 +530,7 @@ describe('host rewind', () => { it('settles the existing epoch after a crash between journal commit and record completion', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const transition = store.transitionHandoff.bind(store) const checkpoint = vi .spyOn(store, 'transitionHandoff') @@ -536,7 +544,7 @@ describe('host rewind', () => { }) ) await expect(host.rewind(caller, request)).rejects.toThrow('completion write failed') - const committed = host.journalSnapshot(HOST_TEST_SESSION) + const committed = await host.journalSnapshot(HOST_TEST_SESSION) expect(committed.cursor.epoch).not.toBe(request.expectedEpoch) checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') @@ -546,7 +554,7 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) expect(replace).not.toHaveBeenCalled() replace.mockRestore() expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index baedbe7c4d19..45dd7244896e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -13,6 +13,7 @@ import type { import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { openWithAgent } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' @@ -35,6 +36,8 @@ export async function rewindStructuredAgentSession( adapter: context.deps.adapter, callerKey: caller.callerKey, envelope: params.envelope, + // Only the provider can do this, so an agent at rest is started first. + prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index baadfbe82d8f..ba466fc3bc6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -26,11 +26,9 @@ import { const CALLER = { callerKey: 'client-1' } /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } -// Long enough that no release fires mid-test; whether one is pending is asserted directly. -const GRACE_MS = 60_000 let root: string let store: AgentSessionRecordStore @@ -83,7 +81,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: GRACE_MS, now: () => NOW, onEventSinkError: ({ error }) => hostErrors.push(error) }) @@ -121,28 +118,26 @@ async function accept(params: ReturnType): Promise { return params.envelope.clientOperationId } -function submission(clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } /** The submission once delivery is done with it: handed over, or rejected unwritten. */ async function settled(clientMessageId: string) { - await eventually(() => { - const current = submission(clientMessageId) + await eventually(async () => { + const current = await submission(clientMessageId) expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) }) return submission(clientMessageId) } /** The failure rows a start the chat needed left, oldest first. */ -function errorStatuses(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) +async function errorStatuses(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) } /** The child timed out or exited: its lease is handed back and the host holds no session. */ @@ -161,7 +156,7 @@ describe('a send with no live owner', () => { await accept(sendParams('after the child died')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) @@ -181,7 +176,7 @@ describe('a send with no live owner', () => { }) await accept(sendParams('accept first')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(order).toEqual(['admit', 'acquire']) }) @@ -191,7 +186,7 @@ describe('a send with no live owner', () => { await accept(sendParams('owner is live')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).not.toHaveBeenCalled() }) @@ -217,30 +212,6 @@ describe('a send with no live owner', () => { expect(acquire).not.toHaveBeenCalled() }) - it('renews the idle window on journal activity in an unheld session', async () => { - await loseOwner() - await accept(sendParams('restart')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - const arm = vi.spyOn(host['holds']['clock'], 'arm') - - await accept(sendParams('more activity')) - - await eventually(() => expect(arm).toHaveBeenCalledWith(SESSION)) - }) - - it('releases the restarted child on the usual clock only when no surface holds it', async () => { - await loseOwner() - await accept(sendParams('nobody is watching')) - await eventually(() => expect(host['holds'].isReleasePending(SESSION)).toBe(true)) - - await host.close(SESSION) - // A reading surface that does not itself restart the agent. - await host.hold(SESSION, 'desktop-chat:1', { resume: false }) - await accept(sendParams('the chat is open')) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - }) - it('restarts an owner that exited while the session stayed readable', async () => { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -256,14 +227,14 @@ describe('a send with no live owner', () => { acquire.mockClear() await accept(sendParams('after an exit')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() }) it('restarts nothing for a resend the journal already answers', async () => { const params = sendParams('sent once') await accept(params) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) // The child died during startup: the lease is handed back, the fence moves, and the session // stays readable. The client resends against the new fence. await host.handleAdapterEvent({ @@ -293,7 +264,7 @@ describe('a send with no live owner', () => { // Retry rotates the id: a genuinely new send restarts the owner once. await accept(sendParams('sent once')) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() }) @@ -365,7 +336,7 @@ describe('a send with no live owner', () => { release() expect(await second).toMatchObject({ ok: true }) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ first, @@ -383,30 +354,14 @@ describe('a send with no live owner', () => { ]) expect(results.map((result) => result.ok)).toEqual([true, true, true]) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(3)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) expect(acquire).toHaveBeenCalledOnce() }) - it('shares one restart between a hold and a send that arrive in the same gap', async () => { - await loseOwner() - - const [held, sent] = await Promise.allSettled([ - host.hold(SESSION, 'desktop-chat:1'), - host.send(CALLER, sendParams('while the chat opens')) - ]) - - expect(held).toMatchObject({ status: 'fulfilled' }) - expect(sent).toMatchObject({ status: 'fulfilled', value: { ok: true } }) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds'].isHeld(SESSION)).toBe(true) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - }) - it('replays into a closed session without spawning anything', async () => { const params = sendParams('sent once') await accept(params) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) await loseOwner() await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) @@ -417,7 +372,6 @@ describe('a send with no live owner', () => { expect(acquire).not.toHaveBeenCalled() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(host['holds'].isReleasePending(SESSION)).toBe(false) }) it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { @@ -436,7 +390,7 @@ describe('a send with no live owner', () => { store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) ).toMatchObject({ outcome: { status: 'succeeded' } }) // One row, in the error tone, so the reason outlives the error strip. - expect(errorStatuses()).toEqual([cause]) + expect(await errorStatuses()).toEqual([cause]) }) it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { @@ -453,14 +407,14 @@ describe('a send with no live owner', () => { value: { submission: { dispatchState: 'rejected' } } }) expect(acquire).toHaveBeenCalledTimes(1) - expect(errorStatuses()).toHaveLength(1) + expect(await errorStatuses()).toHaveLength(1) // The outbox's Retry rotates the id: a fresh attempt, with its own row. expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledTimes(2) - expect(errorStatuses()).toHaveLength(2) + expect(await errorStatuses()).toHaveLength(2) expect(dispatch).not.toHaveBeenCalled() }) @@ -473,7 +427,7 @@ describe('a send with no live owner', () => { // The user signed in; nothing about the failed attempt is remembered. await accept(sendParams('signed in now')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) @@ -498,7 +452,10 @@ describe('a send with no live owner', () => { it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { await loseOwner() - vi.spyOn(host['holds'], 'ensureProviderChild').mockResolvedValueOnce({ + vi.spyOn( + host['conversationDelivery'].loop['deps'], + 'ensureProviderChild' + ).mockResolvedValueOnce({ ok: false, refusal: { code: 'execution_owner_reconciling', @@ -511,14 +468,15 @@ describe('a send with no live owner', () => { const cause = "Codex couldn't restart: Another runtime is still adjudicating this lease." expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) expect(acquire).not.toHaveBeenCalled() - expect(errorStatuses()).toEqual([cause]) + expect(await errorStatuses()).toEqual([cause]) }) it('rejects the message, and reports the fault, when the restart itself faults', async () => { await loseOwner() - vi.spyOn(host['holds'], 'ensureProviderChild').mockRejectedValueOnce( - new Error('spawn-token mint failed') - ) + vi.spyOn( + host['conversationDelivery'].loop['deps'], + 'ensureProviderChild' + ).mockRejectedValueOnce(new Error('spawn-token mint failed')) const id = await accept(sendParams('bookkeeping failed')) @@ -529,125 +487,10 @@ describe('a send with no live owner', () => { expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) ) - expect(errorStatuses()).toHaveLength(1) - }) - - it('keeps a second surface holder taken during an auto-restart, and starts nothing for it', async () => { - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - - // The held child exits: the host restarts it on its own, under the surface that holds it. - const restarted = host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - await entered.promise - const second = host.hold(SESSION, 'paired-phone:1') - gate.resolve() - await Promise.all([restarted, second]) - - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds']['holders'].holderIds(SESSION)).toEqual([ - 'desktop-chat:1', - 'paired-phone:1' - ]) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('puts the child of an auto-restart on the idle clock when its surface left mid-attach', async () => { - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - - const restarted = host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - await entered.promise - // The only surface leaves while the host is still spawning the child it asked for. - host.release(SESSION, 'desktop-chat:1') - gate.resolve() - await restarted - - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds'].isHeld(SESSION)).toBe(false) - // Nobody holds the child, so it goes on the same clock a departed surface would start. - expect(host['holds'].isReleasePending(SESSION)).toBe(true) - expect(hostErrors).toEqual([]) - }) - - it('counts a queued restart as in flight from the moment it is asked for, so a quit drains it', async () => { - const closeSession = vi.mocked(host.deps.adapter.closeSession!) - acquire.mockClear() - const gate = Promise.withResolvers() - closeSession.mockImplementationOnce(async () => { - await gate.promise - return true - }) - const closing = host.close(SESSION) - const hold = host.hold(SESSION, 'desktop-chat:1') - let drained = false - void host['tasks'].drainAttaches().then(() => { - drained = true - }) - await new Promise((resolve) => setImmediate(resolve)) - - // The hold waits its turn behind the close, and the quit's drain waits for the hold: the - // child it is about to spawn must exist before the quit decides what to evict. - expect(acquire).not.toHaveBeenCalled() - expect(drained).toBe(false) - - gate.resolve() - await Promise.all([closing, hold]) - await new Promise((resolve) => setImmediate(resolve)) - expect(acquire).toHaveBeenCalledOnce() - expect(drained).toBe(true) - }) - - it('adjudicates a lease this host has not reconciled before a hold resumes it', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - - await host.hold(SESSION, 'desktop-chat:1') - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'live' - }) + expect(await errorStatuses()).toHaveLength(1) }) - it('exits the recovery stage a failed attempt latched before a hold resumes', async () => { + it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { await loseOwner() // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. await store.transitionHandoff(SESSION, (current) => ({ @@ -656,8 +499,9 @@ describe('a send with no live owner', () => { })) host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - await host.hold(SESSION, 'desktop-chat:1') + await accept(sendParams('latched owner')) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -675,7 +519,7 @@ describe('a send with no live owner', () => { // The send's start is the same serialized resume a hold runs, reconciliation first. await accept(sendParams('owner unverified')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ unreconciled: false, @@ -706,7 +550,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { it("keeps the pane's fence on the rows a failed start publishes, and rejects the message", async () => { const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const frames: AgentSessionSubscribeEvent[] = [] - host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) + await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) await loseOwner() acquire.mockRejectedValueOnce(new Error('Not signed in')) const subscribed = frames.length @@ -767,7 +611,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await settled(late.envelope.clientOperationId)).toMatchObject({ dispatchState: 'accepted' }) - expect(submission(firstParams.envelope.clientOperationId)).toMatchObject({ + expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledOnce() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 1fa923e971f5..d95369a04947 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -21,6 +21,7 @@ import { type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' import { rewindRefusal } from './structured-rewind-refusal' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' /** * Whether a refused start leaves the chat anything to start again from. `unresumable`: this host @@ -95,6 +96,40 @@ export async function openConversationForWrite( } } +/** The conversation a write lands in, opened when this host holds it closed. */ +export function openForWrite( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return () => openConversationForWrite(context.openConversation, envelope) +} + +/** For an operation only the provider can perform: the conversation, then its agent. */ +export function openWithAgent( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return async () => { + const opened = await openConversationForWrite(context.openConversation, envelope) + return opened.ok ? context.ensureAgent(envelope.sessionId) : opened + } +} + +/** A rewind still in doubt once the conversation is open is one only its provider can settle — + * the open settles every other — so a send starts the agent, whose attach recovers it. */ +export function sendPreparation( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return async () => { + const opened = await openConversationForWrite(context.openConversation, envelope) + const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase + return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') + ? context.ensureAgent(envelope.sessionId) + : opened + } +} + /** What the chat says, in its row and on every message it rejects, when the delivery loop could * not make the session ready. A child that died starting reads as any start that died does. */ export function structuredAgentSessionStartFailureText( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 211d8684e151..e1bb80e84599 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -29,7 +29,7 @@ import { const CALLER = { callerKey: 'client-1' } /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -70,7 +70,9 @@ async function send( value: { submission: { dispatchState: 'pending', handoverRecorded: true } } }) const clientMessageId = sent.ok ? sent.value.clientMessageId : '' - await eventually(() => expect(submission(clientMessageId)?.handedOverAt).toBeDefined()) + await eventually(async () => + expect((await submission(clientMessageId))?.handedOverAt).toBeDefined() + ) return clientMessageId } @@ -102,16 +104,16 @@ function exitBeforeProof(): Promise { }) } -function journalStatuses(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +async function journalStatuses(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } -function submission(clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } beforeEach(async () => { @@ -188,22 +190,22 @@ describe('a send into a published session whose child ended before startup', () it('retires the held message with the cause when the restarted child exits before proving its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const rowsBefore = journalStatuses().length + const rowsBefore = (await journalStatuses()).length const held = await send('still not signed in', releasedFence) await exitBeforeProof() // The child never proved its start, so it accepted nothing: the exit rejects the message this // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. - expect(submission(held)).toMatchObject({ + expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', reason: expect.stringContaining(EXIT_REASON), recovered: true }) expect( - host.journalSnapshot(SESSION).submissions.filter((e) => e.dispatchState === 'pending') + (await host.journalSnapshot(SESSION)).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) - expect(journalStatuses().slice(rowsBefore)).toEqual([ + expect((await journalStatuses()).slice(rowsBefore)).toEqual([ expect.stringMatching(/stopped before it finished starting: .*not signed in/) ]) // The failed restart moved the fence twice: the acquisition, and the exit that released it. @@ -216,7 +218,7 @@ describe('a send into a published session whose child ended before startup', () await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledTimes(2) - expect(journalStatuses().slice(rowsBefore)).toHaveLength(1) + expect((await journalStatuses()).slice(rowsBefore)).toHaveLength(1) }) }) @@ -230,7 +232,7 @@ describe('a send while the child of the first start is still proving itself', () await proveStarted() expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual([]) + expect(await journalStatuses()).toEqual([]) }) it('is retired with the cause when that child exits first, and restarts nothing', async () => { @@ -239,13 +241,13 @@ describe('a send while the child of the first start is still proving itself', () await exitBeforeProof() - expect(submission(held)).toMatchObject({ + expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', reason: expect.stringContaining(EXIT_REASON), recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual([ + expect(await journalStatuses()).toEqual([ expect.stringMatching(/stopped before it finished starting: .*not signed in/) ]) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index 1a6e5606c1c7..24e55bbba636 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -75,7 +75,7 @@ describe('send', () => { dispatchState: 'accepted' }) expect(dispatch).toHaveBeenCalledTimes(1) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(1) expect(page.ok && page.page.fence).toBe(1) expect(page.page.hostNow).toBe(NOW) @@ -140,7 +140,7 @@ describe('send', () => { value: { submission: { dispatchState: 'unknown' } } }) expect(dispatch).toHaveBeenCalledTimes(1) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(1) }) @@ -167,7 +167,7 @@ describe('send', () => { } }) expect(dispatch).toHaveBeenCalledTimes(1) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(1) }) @@ -196,7 +196,7 @@ describe('send', () => { dispatchState: 'accepted' }) expect(dispatch).toHaveBeenCalledTimes(2) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(2) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 8a7a6ad76473..bdf46319bda2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -125,6 +125,11 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +/** The host starting the agent with no message to deliver, as an operation that needs it does. */ +function startAgent(): Promise { + return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) +} + describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const historyFilePath = vi @@ -297,7 +302,7 @@ describe('settled attach retry', () => { ).toEqual({ status: 'pending' }) reservationUnused = true - await host.hold(SESSION, 'desktop-chat:retry') + await startAgent() expect(mintSpawnToken).toHaveBeenCalledTimes(2) expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -337,7 +342,7 @@ describe('settled attach retry', () => { now: () => NOW }) await host.restoreReadableSessions() - await host.hold(SESSION, 'desktop-chat:restart') + await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, @@ -354,7 +359,7 @@ describe('settled attach retry', () => { throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) } await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) - const restoredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + const restoredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) if (!restoredHistory.ok) { throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts index ef612b3a5081..1b578344bdb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts @@ -1,8 +1,8 @@ // A Claude chat is published before its CLI answers initialize, and a message sent in that window // is accepted and stays queued until it does; the delivery loop hands it over once startup lands. -// Switching away from the chat starts the release clock; the clock must treat that queued message -// as work still owed, exactly as it treats a running turn, or it evicts the session and rejects a -// message the user already sent. +// The idle sweep ticks meanwhile. Inside the idle window it must leave that queued message and the +// starting child alone, and every journal publish — the handover included — starts the window +// again; only a chat quiet for the whole window loses its agent. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -27,8 +27,8 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } -const SURFACE = 'desktop-chat:1' -const GRACE_MS = 5 +const SWEEP_MS = 5 +const IDLE_MS = 1_000 let root: string let store: AgentSessionRecordStore @@ -37,12 +37,14 @@ let adapter: ClaudeStructuredSessionAdapter let claude: ReturnType let landInit: () => void let lifecycle: Promise[] +let clock: number beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-starting-release-')) resetHostTestOperationIds() claude = fakeClaude() lifecycle = [] + clock = NOW const initLanded = new Promise((resolve) => { landInit = resolve }) @@ -85,8 +87,8 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - releaseGraceMs: GRACE_MS, - now: () => NOW + idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, + now: () => clock }) }) @@ -109,7 +111,6 @@ async function attachStarting(): Promise { }) ) expect(created).toMatchObject({ ok: true }) - await host.hold(SESSION, SURFACE) } async function send(text: string, dispatchState = 'pending'): Promise { @@ -131,36 +132,36 @@ async function send(text: string, dispatchState = 'pending'): Promise { return sent.ok ? sent.value.clientMessageId : '' } -function dispatchState(clientMessageId: string): string | undefined { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId)?.dispatchState +async function dispatchState(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + )?.dispatchState } /** The delivery loop hands a message over on its own serialized steps after startup lands; this - * yields to them without advancing the (possibly faked) release clock. */ + * yields to them without moving the host clock. */ async function untilSent(connection: { sent: unknown[] }): Promise { for (let turn = 0; turn < 2000 && connection.sent.length === 0; turn += 1) { await new Promise((resolve) => setImmediate(resolve)) } } -/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ -function waitOutSeveralGraceWindows(): Promise { - return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } -describe('a chat left while its Claude CLI is still starting', () => { - it('keeps the session for a message still queued, and delivers it once startup lands', async () => { +describe('a Claude chat whose CLI is still starting', () => { + it('keeps a message queued inside the idle window, and delivers it once startup lands', async () => { await attachStarting() const held = await send('sent while starting') - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) - expect(dispatchState(held)).toBe('pending') + expect(await dispatchState(held)).toBe('pending') landInit() await adapter.awaitStarted(SESSION) @@ -169,11 +170,10 @@ describe('a chat left while its Claude CLI is still starting', () => { () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), { timeout: 3000 } ) - await vi.waitFor(() => expect(dispatchState(held)).toBe('accepted')) + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) }) - it('gives the message it wrote at startup a full grace to open its turn', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + it('gives the message it hands over at startup a full idle window to open its turn', async () => { await attachStarting() const held = await send('sent while starting') const connection = claude.connections[0] @@ -181,33 +181,34 @@ describe('a chat left while its Claude CLI is still starting', () => { connection.send = async (message) => { connection.sent.push(message) } - host.release(SESSION, SURFACE) - await vi.advanceTimersByTimeAsync(GRACE_MS * 3 - 1) + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - // Startup lands just before the clock's next tick. + // Startup lands just before the window closes; the handover's publish starts it again. landInit() await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) await untilSent(connection) expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) - await vi.advanceTimersByTimeAsync(GRACE_MS - 1) + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(connection.closeCount).toBe(0) connection.handlers.onMessage?.(connection.sent[0]) await host.flushStreamedEvents(SESSION) - await vi.advanceTimersByTimeAsync(GRACE_MS * 3) + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - expect(dispatchState(held)).toBe('accepted') + expect(await dispatchState(held)).toBe('accepted') }) - it('is released after the grace once its turn has finished', async () => { + it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() await adapter.awaitStarted(SESSION) const answered = await send('answered') - await vi.waitFor(() => expect(dispatchState(answered)).toBe('accepted')) + await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ type: 'result', subtype: 'success', @@ -217,18 +218,18 @@ describe('a chat left while its Claude CLI is still starting', () => { result: 'done' }) await host.flushStreamedEvents(SESSION) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) - host.release(SESSION, SURFACE) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) }) - it('is released after the grace when it owes nothing', async () => { + it('stops a start that stayed quiet for the whole window when it owes nothing', async () => { await attachStarting() - host.release(SESSION, SURFACE) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index ab2c8f0b9e80..79780b5bed14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -54,7 +54,6 @@ function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } @@ -71,15 +70,14 @@ const ended = { } describe('a provider that ends before it finished starting', () => { - it('tells the user why, even with no response in progress, and does not auto-resume', async () => { + it('tells the user why, even with no response in progress', async () => { const session = startedSession() - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { ...ended, startupUnproven: true }) - expect(ticket).toBeNull() expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ mutations: [ @@ -94,12 +92,12 @@ describe('a provider that ends before it finished starting', () => { expect(providerStartupFailureOutcome(REASON)).toContain('not signed in') }) - it('keeps an ordinary idle exit silent and resumable', async () => { + it('keeps an ordinary idle exit silent', async () => { const session = startedSession() - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) - expect(ticket).not.toBeNull() + expect(session.child).toBeNull() expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() }) @@ -109,9 +107,8 @@ describe('a provider that ends before it finished starting', () => { child: { generation: GENERATION, fence: 7, phase: 'starting' as const } } - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) - expect(ticket).toBeNull() expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ mutations: [ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 5f768ee9b808..2989fb1d3e5a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -1,7 +1,8 @@ -// The lifetime of a provider child, from the surfaces that hold the session. +// The lifetime of a provider child, against the real host rather than a double. // -// Two leaks meet here and each has to be tested against the real host, not a double: a chat that -// closes without stopping its app-server, and a launch that starts one for every record on disk. +// Two leaks meet here: a chat that closes without stopping its app-server, and a launch that +// starts one for every record on disk. A view never starts or keeps a child; work starts one, the +// idle sweep stops it, and an exit is settled and left for the next send. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -16,7 +17,6 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { AgentSessionAcquisitionRootExitObservedError, @@ -37,9 +37,9 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } -const SURFACE = 'desktop-chat:1' -/** Short enough to keep the suite fast, long enough that an eviction is a decision and not a race. */ -const GRACE_MS = 5 +/** Short enough to keep the suite fast; the host clock below decides what is idle. */ +const SWEEP_MS = 5 +const IDLE_MS = 1_000 let root: string let store: AgentSessionRecordStore @@ -50,6 +50,7 @@ let dispatch: Mock let sink: StructuredAgentSessionEventSink | null let hostErrors: unknown[] let statusSink: StructuredAgentSessionStatusSink +let clock: number function adapter(): StructuredAgentSessionAdapter { return { acquire, @@ -71,8 +72,8 @@ function openHost( journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: GRACE_MS, - now: () => NOW, + idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, + now: () => clock, onEventSinkError: ({ error }) => hostErrors.push(error), statusSink, ...(probeOwner ? { probeOwner } : {}) @@ -92,6 +93,12 @@ async function attach(): Promise { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) } +/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +async function startAgent(): Promise { + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) +} + function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { sessionId: SESSION, @@ -112,7 +119,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi ) } -/** Eviction is a sequence, not an event: the child stops first and the session is forgotten last. */ +/** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { expect(closeSession).toHaveBeenCalledWith(SESSION) @@ -120,9 +127,9 @@ function waitForEviction(): Promise { }) } -/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ -function waitOutSeveralGraceWindows(): Promise { - return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } /** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. */ @@ -188,6 +195,7 @@ beforeEach(async () => { resetHostTestOperationIds() sink = null hostErrors = [] + clock = NOW statusSink = { publish: vi.fn(), forget: vi.fn() } let generation = 0 acquire = vi.fn(async ({ fence, spawnToken, events }) => { @@ -218,13 +226,13 @@ afterEach(async () => { }) describe('a chat that closes', () => { - it('releases the provider child it was holding', async () => { + it('stops the provider child it started', async () => { await attach() - await host.hold(SESSION, SURFACE) - host.release(SESSION, SURFACE) + await host.close(SESSION) - await waitForEviction() + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -234,49 +242,24 @@ describe('a chat that closes', () => { }) }) - it('keeps the child while another surface still holds the session', async () => { - await attach() - await host.hold(SESSION, SURFACE) - await host.hold(SESSION, 'paired-phone:1') - - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - // The pane outlives the close by a few frames — a workspace delete closes the chats inside it - // while their panes are still mounted — so whatever a read raises in that window is what the user - // sees. This is the code the client narrows on to keep that window off the pane; a host that - // starts raising a different one there puts the red error back. - it('answers a read from the pane that outlived it with the code the client treats as transitional', async () => { + // while their panes are still mounted. A read in that window reopens the conversation as a cache + // and is answered, never refused; it starts nothing. + it('answers a read from the pane that outlived it without starting a child', async () => { await attach() - await host.hold(SESSION, SURFACE) await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(() => host.history({ sessionId: SESSION, direction: 'tail' })).toThrow( - AGENT_SESSION_UNATTACHED_REFUSAL_CODE - ) - expect(() => - host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: () => undefined }) - ).toThrow(AGENT_SESSION_UNATTACHED_REFUSAL_CODE) - }) - - it('does not lose the session to a release the client sent twice', async () => { - await attach() - await host.hold(SESSION, SURFACE) - await host.hold(SESSION, 'paired-phone:1') - - // A retried release must retire ONE holder, which is what a set gets right and a count does not. - host.release(SESSION, SURFACE) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) + const unsubscribe = await host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: () => undefined + }) + unsubscribe() + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) it('answers a compatibility wait with what eviction recorded', async () => { @@ -308,7 +291,7 @@ describe('a chat that closes', () => { }) }) - it('retries teardown after journal close loses its result', async () => { + it('keeps the stop it made when the journal close loses its result', async () => { await attach() const session = host['sessions'].get(SESSION) expect(session).toBeDefined() @@ -320,25 +303,16 @@ describe('a chat that closes', () => { }) .mockImplementation(closeJournal) - // The child stopped and its lease went back; only the conversation's close is left to retry. + // The child is stopped and the lease released before the handle closes; the entry is dropped + // before that close, so a lost result leaves no closing handle for a reader to find. await expect(host.close(SESSION)).rejects.toThrow('journal close result lost') - expect(host.hasSession(SESSION)).toBe(true) - expect(host['sessions'].get(SESSION)?.child).toBeNull() + expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null }) - expect(statusSink.forget).toHaveBeenCalledWith({ - kind: 'structured-session', - sessionId: SESSION, - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }) await expect(host.close(SESSION)).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(false) expect(closeSession).toHaveBeenCalledOnce() }) @@ -375,31 +349,31 @@ describe('a chat that closes', () => { }) describe('a session with a turn in flight', () => { - it('is not evicted while the turn runs, and is once it ends', async () => { + it('is not stopped while the turn runs, and is once it ends and idles', async () => { await attach() - await host.hold(SESSION, SURFACE) emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS + await waitOutSeveralSweeps() expect(closeSession).not.toHaveBeenCalled() expect(host.hasSession(SESSION)).toBe(true) emitTurnLifecycle('completed', 2) await host.flushStreamedEvents(SESSION) + clock += IDLE_MS await waitForEviction() }) - // Codex settles an admitted send only on its echo, which may never come; eviction retires it. - it('is evicted with an admitted send outstanding once no turn runs', async () => { + // Codex settles an admitted send only on its echo, which may never come; the stop retires it. + it('is stopped with an admitted send outstanding once no turn runs', async () => { await attach() - await host.hold(SESSION, SURFACE) await sendPending('admitted, never echoed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - host.release(SESSION, SURFACE) + clock += IDLE_MS await waitForEviction() }) @@ -410,7 +384,7 @@ describe('startup', () => { await attach() const beforeRestart = store.getRecord(SESSION) host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() + host['lifetime'].dispose() await host['sessions'].get(SESSION)?.journal.close() host['sessions'].clear() @@ -418,7 +392,7 @@ describe('startup', () => { openHost(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() - const restored = host.history({ sessionId: SESSION, direction: 'tail' }) + const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( false ) @@ -428,7 +402,7 @@ describe('startup', () => { settlementRetryRequired: undefined }) - await host.hold(SESSION, SURFACE) + await startAgent() expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( beforeRestart?.providerHandleChain.at(-1)?.handle ) @@ -446,16 +420,23 @@ describe('startup', () => { expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) - expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('gives the child back to a chat a surface actually opens', async () => { + it('gives the child back for work, never for a read', async () => { await attach() await reboot() await host.restoreReadableSessions() - await host.hold(SESSION, SURFACE) + const unsubscribe = await host.subscribe({ + id: 'viewer-1', + sessionId: SESSION, + emit: () => undefined + }) + expect(acquire).not.toHaveBeenCalled() + await startAgent() + unsubscribe() expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -466,16 +447,15 @@ describe('startup', () => { }) }) -describe('a session evicted and opened again', () => { +describe('a session closed and started again', () => { it('publishes provider events to the reattached chat', async () => { await attach() - await host.hold(SESSION, SURFACE) - host.release(SESSION, SURFACE) - await waitForEviction() + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) - await host.hold(SESSION, 'desktop-chat:2') + await startAgent() const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'subscriber-1', sessionId: SESSION, emit: (event) => events.push(event) @@ -492,10 +472,10 @@ describe('a session evicted and opened again', () => { }) }) -function submissionState(clientMessageId: string): string | undefined { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId)?.dispatchState +async function submissionState(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + )?.dispatchState } describe('an unexpected provider exit', () => { @@ -539,7 +519,7 @@ describe('an unexpected provider exit', () => { await failJournalSinkUntilReleased() expect(dispatch).not.toHaveBeenCalled() - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect( history.ok && history.page.items.some( @@ -561,9 +541,8 @@ describe('an unexpected provider exit', () => { replaceFailedSink() }) - it('releases the exact generation, reacquires outside the queue, and dispatches a new message', async () => { + it('releases the exact generation, starts nothing, and the next message starts a child', async () => { await attach() - await host.hold(SESSION, SURFACE) dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) const unknownBody = hostTestMessage('message with unknown delivery') const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) @@ -571,8 +550,8 @@ describe('an unexpected provider exit', () => { host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. - await vi.waitFor(() => - expect(submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') + await vi.waitFor(async () => + expect(await submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') ) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -585,17 +564,17 @@ describe('an unexpected provider exit', () => { acquisitionGeneration: 'generation-1' }) - const recoveredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + const recoveredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) expect( recoveredHistory.ok && hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) ).toBe(false) - expect(acquire).toHaveBeenCalledTimes(2) + // No respawn: the exit is settled and shown, and the conversation waits for work. + expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeFence: exitedFence + 2, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + runtimeFence: exitedFence + 1 }) dispatch.mockResolvedValueOnce({ state: 'accepted', @@ -607,13 +586,20 @@ describe('an unexpected provider exit', () => { ok: true, value: { submission: { dispatchState: 'pending' } } }) - await vi.waitFor(() => expect(submissionState(nextEnvelope.clientOperationId)).toBe('accepted')) + await vi.waitFor(async () => + expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') + ) + expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) }) - it('does not reacquire for a subscription-only hold or a stale child generation', async () => { + it('does not reacquire for a live reader or a stale child generation', async () => { await attach() - await host.hold(SESSION, 'subscriber-1', { resume: false }) + const unsubscribe = await host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: () => undefined + }) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -641,11 +627,11 @@ describe('an unexpected provider exit', () => { runtimeFence: exitedFence + 1, deathEvidence: { kind: 'exit-observed' } }) + unsubscribe() }) it('keeps a requested close out of recovery', async () => { await attach() - await host.hold(SESSION, SURFACE) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -663,7 +649,6 @@ describe('an unexpected provider exit', () => { it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { await attach() - await host.hold(SESSION, SURFACE) dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) const unknownBody = hostTestMessage('message with unknown delivery') const unknownParams = { @@ -674,8 +659,8 @@ describe('an unexpected provider exit', () => { ok: true, value: { submission: { dispatchState: 'pending' } } }) - await vi.waitFor(() => - expect(submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') + await vi.waitFor(async () => + expect(await submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') ) const runtimeState = ( host as unknown as { @@ -698,14 +683,13 @@ describe('an unexpected provider exit', () => { }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeFence: exitedFence + 2, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + runtimeFence: exitedFence + 1 }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is // written — carrying the cause, and never the old bare `Provider exited: ` row. @@ -725,13 +709,14 @@ describe('an unexpected provider exit', () => { ok: true, value: { submission: { dispatchState: 'pending' } } }) - await vi.waitFor(() => expect(submissionState(nextEnvelope.clientOperationId)).toBe('accepted')) + await vi.waitFor(async () => + expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') + ) expect(dispatch).toHaveBeenCalledTimes(2) }) it('latches a failed exit settlement and blocks attach until the terminal batch is written', async () => { await attach() - await host.hold(SESSION, SURFACE) emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) const runtimeState = ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index fd04a946aa30..013e3cdacb43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -10,53 +10,14 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { - isStructuredAgentSessionRecoveryTicketCurrent, settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, - type StructuredAgentSessionUnexpectedExitSession, - type StructuredAgentSessionRecoveryTicket + type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' const SESSION = 'session-1' const GENERATION = 'generation-1' -const ticket: StructuredAgentSessionRecoveryTicket = { - sessionId: SESSION, - releasedFence: 8, - deadAcquisitionGeneration: GENERATION, - stableSettlementId: 'settlement-1' -} - -function recoveryContext(input: { - generation?: string - handoffStage?: AgentSessionRecord['lease']['handoffStage'] - resumeCapable?: boolean -}) { - const session: Pick = { - child: null, - lastEndedChild: { - generation: input.generation ?? GENERATION, - fence: 7, - cause: 'exit', - reason: null, - duringStartup: false, - rootGone: true - } - } - const record = { - lease: { - runtimeFence: 8, - claimStatus: 'released', - handoffStage: input.handoffStage ?? null - } - } as AgentSessionRecord - return { - sessions: new Map([[SESSION, session]]), - store: { getRecord: () => record }, - hasResumeCapableHolder: () => input.resumeCapable ?? true - } as never -} - function lifecycleItem( turnId: string, sequence: number, @@ -108,7 +69,7 @@ function mutableStore() { } } -describe('provider-exit recovery tickets', () => { +describe('provider-exit settlement', () => { it.each([undefined, 2_000])('keeps exit receipt %s on retry', async (observedAt) => { let now = observedAt === undefined ? 2_000 : 30_000 let record = { @@ -154,7 +115,6 @@ describe('provider-exit recovery tickets', () => { return { ok: false, error: new Error('sink unavailable') } }, publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => now } as never, @@ -226,13 +186,12 @@ describe('provider-exit recovery tickets', () => { transitionHandoff: async () => ({ lease: { runtimeFence: 8 } }) } - const result = await settleUnexpectedStructuredAgentSessionExit( + await settleUnexpectedStructuredAgentSessionExit( { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => 1_234 } as never, @@ -247,7 +206,6 @@ describe('provider-exit recovery tickets', () => { } ) - expect(result).toMatchObject({ releasedFence: 8 }) expect(session.journal.markPendingSubmissionsUnknown).toHaveBeenCalledWith( 7, 'provider_exited_before_acknowledgement' @@ -333,7 +291,6 @@ describe('provider-exit recovery tickets', () => { return { ok: true } }, publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1_234 } @@ -380,7 +337,6 @@ describe('provider-exit recovery tickets', () => { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } @@ -438,44 +394,14 @@ describe('provider-exit recovery tickets', () => { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: false, error: new Error('sink failed') }), publishFence, - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => 1, onBarrierError: release } - const result = await settleUnexpectedStructuredAgentSessionExit(context, event) + await settleUnexpectedStructuredAgentSessionExit(context, event) - expect(result).toBeNull() expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) expect(release).toHaveBeenCalledTimes(2) }) - - it('admits the exact released generation for a resume-capable holder', () => { - expect(isStructuredAgentSessionRecoveryTicketCurrent(recoveryContext({}), ticket)).toBe(true) - }) - - it('is cancelled by a latched stage before reattachment', () => { - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ handoffStage: 'recovering' }), - ticket - ) - ).toBe(false) - }) - - it('is cancelled when its holder or dead acquisition generation is no longer current', () => { - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ resumeCapable: false }), - ticket - ) - ).toBe(false) - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ generation: 'generation-new' }), - ticket - ) - ).toBe(false) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index f30521ab8077..a62fafbe9197 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -19,13 +19,6 @@ type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { cause: 'unexpected-exit' } -export type StructuredAgentSessionRecoveryTicket = { - sessionId: string - releasedFence: number - deadAcquisitionGeneration: string - stableSettlementId: string -} - export type StructuredAgentSessionUnexpectedExitSession = Pick< StructuredAgentSessionHostSession, 'child' | 'lastEndedChild' @@ -39,7 +32,6 @@ export type StructuredAgentSessionUnexpectedExitContext< flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: TSession) => void publishStatus?: (sessionId: string) => void - hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number onBarrierError?: (sessionId: string, error: unknown) => void @@ -50,9 +42,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< >( context: StructuredAgentSessionUnexpectedExitContext, event: StructuredAgentSessionEndedEvent -): Promise { +): Promise { if (event.cause !== 'unexpected-exit') { - return null + return } const unexpectedEvent = event as UnexpectedExitLifecycleEvent // Receipt of the exit is the one end time the host may record for a running turn. @@ -66,10 +58,10 @@ export async function settleUnexpectedStructuredAgentSessionExit< child.fence !== unexpectedEvent.fence || child.generation !== unexpectedEvent.acquisitionGeneration ) { - return null + return } // The host's own phase decides, so a provider that omits the flag still gets a start that - // failed told as one: the row says so, and nothing resumes into the same failure. + // failed told as one: the row says so. const exitedDuringStartup = unexpectedEvent.startupUnproven === true || child.phase === 'starting' const endChild = (): void => { @@ -88,15 +80,12 @@ export async function settleUnexpectedStructuredAgentSessionExit< if (!record || record.lease.handoffStage !== null) { // An acquisition or recovery already owns this lease's transition. endChild() - return null + return } let settlementFailed = false const stableSettlementId = providerExitSettlementId(unexpectedEvent) const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) - let released: Awaited< - ReturnType - > | null = null try { try { const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) @@ -126,6 +115,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< } finally { // Provider exit was positively observed, so release the owner even when // terminal settlement could not be durably accepted. + let released: Awaited< + ReturnType + > | null = null try { released = await releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit({ store: context.store, @@ -154,46 +146,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< } } } - if (settlementFailed || !released) { - return null - } - // Resuming a start that failed would respawn into the same failure; the next send retries. - if (exitedDuringStartup || !context.hasResumeCapableHolder(unexpectedEvent.sessionId)) { - return null - } - return { - sessionId: unexpectedEvent.sessionId, - releasedFence: released.lease.runtimeFence, - deadAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, - stableSettlementId - } }) } -export function isStructuredAgentSessionRecoveryTicketCurrent( - context: { - store: Pick - sessions: Map< - string, - Pick - > - hasResumeCapableHolder: (sessionId: string) => boolean - }, - ticket: StructuredAgentSessionRecoveryTicket -): boolean { - const session = context.sessions.get(ticket.sessionId) - const record = context.store.getRecord(ticket.sessionId) - return ( - session !== undefined && - session.child === null && - session.lastEndedChild?.generation === ticket.deadAcquisitionGeneration && - record?.lease.runtimeFence === ticket.releasedFence && - record.lease.claimStatus === 'released' && - record.lease.handoffStage === null && - context.hasResumeCapableHolder(ticket.sessionId) - ) -} - async function retryUnexpectedExitSettlement(input: { context: Pick event: UnexpectedExitLifecycleEvent 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 21a5fe4e49d4..842ddc81e24a 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 @@ -6,9 +6,9 @@ // conversation — the journal, the provider handle chain, and the recorded evidence all survive. // // "Usable" means ACQUIRABLE, not acquired. Startup no longer resumes a provider child for a record -// nobody is looking at; a surface taking a hold is what spawns one. So the migration's job is to -// leave the lease in a state a hold can claim, and these tests prove that by adjudicating it rather -// than by reading fields off it. +// nobody is looking at; work that needs the agent is what spawns one. So the migration's job is to +// leave the lease in a state an attach can claim, and these tests prove that by adjudicating it +// rather than by reading fields off it. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -284,14 +284,19 @@ describe('already-wedged profiles become usable on load', () => { } ) - it('settles restart eviction through attach when a hold arrives before the boot sweep', async () => { + it('settles restart eviction through attach when a start arrives before the boot sweep', async () => { await seedStore( wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) ) await seedRunningTurn() openHost() - await host.hold(SESSION, 'desktop-chat:restart') + // The attach adjudicates the dead owner first, which moves the fence; like a client's ensure, + // it is retried at the fence the refusal names. + const stale = await host.attach(CALLER, hostTestAttachParams(13)) + const fence = stale.ok ? 13 : (stale.refusal.currentFence ?? 13) + expect(stale.ok || stale.refusal.code === 'agent_session_checkpoint_stale').toBe(true) + expect(stale.ok || (await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -523,7 +528,8 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() - await host.hold(SESSION, 'holder-1') + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) expect(order).toEqual(['acquire']) }) 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 37d8d4ee83d7..e6e6d70d6223 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 @@ -117,7 +117,7 @@ describe('host conversation commands', () => { }) expect(compact).toHaveBeenCalledTimes(1) expect(adapter.dispatch).not.toHaveBeenCalled() - const history = host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) expect(history.page.submissions).toEqual([]) expect( history.page.items.some( @@ -204,7 +204,7 @@ describe('host conversation commands', () => { }) expect(store.getRecord(HOST_TEST_SESSION)).not.toBeNull() expect(store.listVisibleSessionIds()).toEqual([nextId]) - expect(host.history({ sessionId: nextId, direction: 'tail' }).page.items).toEqual([]) + expect((await host.history({ sessionId: nextId, direction: 'tail' })).page.items).toEqual([]) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, replayed: true, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 5894defc4839..77dc2082edff 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -16,6 +16,7 @@ import { } from './structured-agent-session-attach' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { openWithAgent } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { conversationCommandBlocked } from './structured-conversation-command-admission' @@ -52,6 +53,8 @@ export function runStructuredConversationCommand( adapter: context.deps.adapter, callerKey: caller.callerKey, envelope, + // Only the provider can do this, so an agent at rest is started first. + prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index 8cf88d685f37..c2477f99e2e6 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -7,7 +7,10 @@ export function replaceAgentSessionRecordOptions( record: AgentSessionRecord, replacement: AgentSessionOptionsReplacement ): AgentSessionRecord { - if (record.lease.runtimeFence !== replacement.fence || record.lease.claimStatus !== 'live') { + const { lease } = record + // At rest the host is the only writer: a pick is intent the next start replays. + const atRest = lease.claimStatus === 'released' && lease.ownerProcess === null + if (lease.runtimeFence !== replacement.fence || (lease.claimStatus !== 'live' && !atRest)) { throw new Error('agent_session_ownership_unknown') } return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 8fcef7e4a48b..8d379388a40b 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -38,11 +38,11 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { await waitForStructuredAgentSessionRecovery() await host.close(SESSION) - // The user reopens it; this time the CLI never answers, then dies, and its tree is unprovable. + // The user reopens it and sends; this time the CLI never answers, then dies, and its tree is + // unprovable. Opening starts nothing: the send does. claude.behave(SESSION, { initHangs: true, closeUnproven: true }) - await host.hold(SESSION, 'surface-1') const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) }) + await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) }) const body = hostTestMessage('hello') const fence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const sent = await host.send(CALLER, { @@ -59,6 +59,8 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { body }) expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // Accepted first; the delivery loop starts the second child after. + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) claude.child(SESSION).exit(new Error(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() @@ -69,11 +71,9 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { ) ) // Never written, so it did not happen: refused, not left in doubt. - const submission = host - .journalSnapshot(SESSION) - .submissions.find( - (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) - ) + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) + ) expect(submission).toMatchObject({ dispatchState: 'rejected' }) }) }) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index da1135f588f4..989c7db1b96b 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -52,16 +52,16 @@ function fence(host: StructuredAgentSessionHost): number { return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } -function statusRows(host: StructuredAgentSessionHost): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } -function submission(host: StructuredAgentSessionHost, clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } /** The CLI keeps dying at startup: the latest child exits with the diagnostic once it exists. */ @@ -82,7 +82,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ok: true }) await failLatestStart(host, 1) - expect(statusRows(host)).toEqual([expect.stringContaining('not signed in')]) + expect(await statusRows(host)).toEqual([expect.stringContaining('not signed in')]) const releasedFence = fence(host) // The delivery loop asks for the child back and the message waits for its start; the CLI @@ -95,14 +95,14 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { await failLatestStart(host, 2) // Rejected with the cause, not left in doubt; one row for this attempt names it. - await vi.waitFor(() => - expect(submission(host, held)).toMatchObject({ + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` }) ) - expect(statusRows(host)).toEqual([ + expect(await statusRows(host)).toEqual([ expect.stringContaining('not signed in'), expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) ]) @@ -120,7 +120,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) - expect(statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(2) }) }) @@ -138,7 +138,7 @@ describe('a send while the first Claude start is still answering initialize', () await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.children(SESSION)).toHaveLength(1) - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) }) it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { @@ -150,14 +150,14 @@ describe('a send while the first Claude start is still answering initialize', () const held = await send(host, 'hello') await failLatestStart(host, 1) - await vi.waitFor(() => - expect(submission(host, held)).toMatchObject({ + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` }) ) - expect(statusRows(host)).toEqual([ + expect(await statusRows(host)).toEqual([ expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) ]) expect(fence(host)).toBe(startedFence + 1) diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 817b00144bca..76596ee7f647 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -18,7 +18,7 @@ const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => unknown): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } @@ -77,16 +77,16 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } -function submission(host: StructuredAgentSessionHost, clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { @@ -139,14 +139,14 @@ describe('a send whose restarted Claude child dies before it proves its start', await failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". - await eventually(() => - expect(submission(host, sent)).toMatchObject({ + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` }) ) - expect(statusRows(host)).toEqual([ + expect(await statusRows(host)).toEqual([ expect.stringContaining('not signed in'), expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) ]) @@ -160,7 +160,7 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => expect(claude.children(SESSION)).toHaveLength(3)) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) - expect(statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(2) }) // A restart refused because its child died before it was handed over leaves one row, from the @@ -177,15 +177,15 @@ describe('a send whose restarted Claude child dies before it proves its start', claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: DIAGNOSTIC, at } }) const sent = await send(host, 'hello?') - await eventually(() => - expect(submission(host, sent)).toMatchObject({ + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` }) ) await waitForStructuredAgentSessionRecovery() - expect(statusRows(host)).toEqual([ + expect(await statusRows(host)).toEqual([ `The provider stopped before it finished starting: ${DIAGNOSTIC}.`, `The provider stopped before it finished starting: ${DIAGNOSTIC}.` ]) @@ -199,7 +199,7 @@ describe('a send whose restarted Claude child dies before it proves its start', ok: true }) const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => { @@ -254,7 +254,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => { @@ -268,8 +268,8 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o // Send: accepted, and its delivery restarts the child, which dies before starting. const sent = await send(host, 'hello?') await failLatestStart(host, 2) - await eventually(() => - expect(submission(host, sent)).toMatchObject({ + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', reason: STARTUP_FAILURE }) @@ -287,8 +287,8 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o } }) const retried = await send(host, 'hello?') - await eventually(() => - expect(submission(host, retried)).toMatchObject({ + await eventually(async () => + expect(await submission(host, retried)).toMatchObject({ dispatchState: 'rejected', reason: STARTUP_FAILURE }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 50527f01ac50..d68623f426eb 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -423,7 +423,7 @@ describe('a structured Claude session over agentSession.*', () => { claude.setSelfExit(null) }) - it('reopens a chat whose stop saw the Claude root exit but not its descendants', async () => { + it('restarts a chat whose stop saw the Claude root exit but not its descendants', async () => { await ok('agentSession.create', createIntentParams()) const first = claude.live() first.exitVerdict = { root: 'exited', tree: 'unverifiable' } @@ -432,14 +432,19 @@ describe('a structured Claude session over agentSession.*', () => { return false } const host = getStructuredAgentSessionHost() - // The idle release clock's eviction: the lease follows the root, so the host lets go. + // The lease follows the root, so the host lets go. await host?.close(SESSION) expect(host?.hasSession(SESSION)).toBe(false) - // What the chat surface's `agentSession.hold` does when the user comes back to it. - await host?.hold(SESSION, 'desktop-chat:reopen') + // The user comes back and sends: that send is what starts Claude again. + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'back' }] } + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, leaseOf(SESSION).runtimeFence), + body + }) - expect(claude.connections).toHaveLength(2) + await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) expect(host?.hasSession(SESSION)).toBe(true) }) @@ -455,10 +460,8 @@ describe('a structured Claude session over agentSession.*', () => { expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) }) - it('restarts an open chat after a Claude crash whose descendants could not be verified', async () => { + it('restarts a chat on its next send after a Claude crash whose descendants could not be verified', async () => { const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - // The open chat surface is what asks the host to bring Claude back. - await getStructuredAgentSessionHost()?.hold(SESSION, 'desktop-chat:open') const connection = claude.live() connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } connection.close = async () => { @@ -481,19 +484,19 @@ describe('a structured Claude session over agentSession.*', () => { dispatchState: 'unknown', reason: 'provider_exited_before_acknowledgement' }) - // Held back, sends failed with the crash until the idle clock stopped the chat. + // The crash releases the lease; nothing restarts Claude until the chat has work for it. await waitForStructuredAgentSessionRecovery() - expect(claude.connections).toHaveLength(2) - expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) - const lease = leaseOf(SESSION) - expect(lease).toMatchObject({ claimStatus: 'live', handoffStage: null }) + expect(claude.connections).toHaveLength(1) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } - const sent = await ok<{ submission: { dispatchState: string } }>('agentSession.send', { - envelope: envelope('agentSession.send', { body: next }, lease.runtimeFence), + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body: next }, leaseOf(SESSION).runtimeFence), body: next }) - expect(sent.submission.dispatchState).toBe('accepted') - expect(claude.live().sent).toHaveLength(1) + await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) + await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'live', handoffStage: null }) }) it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 38d0de314d26..ce26647ae85f 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -72,10 +72,10 @@ function turnReportsModel(model: string): void { }) } -function statusRows(host: StructuredAgentSessionHost): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } async function send(host: StructuredAgentSessionHost, text: string): Promise { @@ -119,7 +119,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' timeout: DEADLINE_MS * 40 }) expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) // The proven child takes the next message. @@ -245,7 +245,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' timeout: DEADLINE_MS * 40 }) expect(record(host)?.lease.claimStatus).toBe('live') - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) }) }) diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 5dd2908413b3..3bc5b570704a 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -4,10 +4,7 @@ import { spawnSurfaceClaimSequence, SURFACE_CLAIM_WITHOUT_STANDING } from './pty-recorded-surface-topology' -import { - observeStructuredWorker, - resolveStructuredWorkerAuthority -} from './structured-worker-authority' +import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import { OrcaRuntimeWithSubscribeToTerminalResize } from './orca-runtime-subscribe-to-terminal-resize' import type { @@ -210,12 +207,10 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim this.getOrchestrationDbIfAvailable?.() ?? null ) if (structured) { - // `resolveBareOrchestrationRecipient` routes direct mail through this, not through - // getTerminalPaneKey. The connected-gate below exists so mail is never routed to a corpse, - // so the structured answer needs a real liveness proof too, not just a registry hit. - return observeStructuredWorker(structured.identity).status === 'live' - ? structured.identity.paneKey - : null + // `resolveBareOrchestrationRecipient` routes direct mail through this. A structured worker is + // a recipient while this runtime owns it, running or at rest: mail to one at rest is a send, + // and the send starts its agent. + return structured.identity.paneKey } const runtimePty = this.getLivePtyForHandle(handle) if (runtimePty) { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index cf7e8e56db1b..d72c250b0976 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -19,6 +19,7 @@ import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' +import { structuredWorkerHasOpenDispatch } from './structured-worker-identity' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' @@ -171,7 +172,11 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis firstWorkRenameDeps(this.requireStore(), this) ) }, - ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}) + ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), + // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps + // its agent running. No database answers no. + hasOpenDispatch: (record) => + structuredWorkerHasOpenDispatch(this.getOrchestrationDbIfAvailable?.() ?? null, record) }) } } diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 50b1f52d238a..75005726c2db 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -216,9 +216,10 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime } // Why: group address resolution (Section 4.5) queries per-handle status and must not throw on stale handles; return null on any error. - getAgentStatusForHandle(handle: string): string | null { + async getAgentStatusForHandle(handle: string): Promise { // A structured worker has no pane and no title, so every PTY probe below answers null and - // `@idle` would enumerate it and then silently drop it. Its status is the journal's. + // `@idle` would enumerate it and then silently drop it. Its status is the journal's, read + // through a conversation the idle sweep may have closed. const structured = resolveStructuredWorkerAuthority(handle, this._orchestrationDb) if (structured) { return structuredWorkerAgentStatus(structured.identity.sessionId) diff --git a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts index 1093319783a6..60047604e438 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts @@ -184,7 +184,7 @@ export class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithGetTermin ): Promise { // Before the PTY lookup, because a structured worker has no PTY and no leaf: without this the // only peer read verb answers `terminal_handle_stale` for a perfectly live worker. - const structured = readStructuredWorkerTerminal({ + const structured = await readStructuredWorkerTerminal({ handle, db: this.getOrchestrationDbIfAvailable?.() ?? null, ...(opts.cursor === undefined ? {} : { cursor: opts.cursor }), diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts index 237afc57bc96..be454a5bde84 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts @@ -142,7 +142,7 @@ describe('OrcaRuntimeService', () => { syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) const [terminal] = (await runtime.listTerminals()).terminals - expect(runtime.getAgentStatusForHandle(terminal.handle)).toBeNull() + await expect(runtime.getAgentStatusForHandle(terminal.handle)).resolves.toBeNull() }) it('lists live terminals with fresh pane titles over stale tab titles', async () => { diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts index 439aca59b017..f46687bee941 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts @@ -81,14 +81,52 @@ export function workerTerminalResourceHasIdentityConflict( ) } +/** Whether a dispatch that has not settled still owns the terminal of this process, read through + * the resource row so a transferred terminal answers for its current owner. */ +export function hasOpenWorkerDispatchForProcess( + this: OrchestrationDb, + params: { processIncarnation: string; hostScope: string } +): boolean { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SELECT names exactly this one column. + const owners = this.db + .prepare( + `SELECT owner_dispatch_id FROM worker_terminal_resources + WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned'` + ) + .all(params.processIncarnation, params.hostScope) as { owner_dispatch_id: string }[] + return owners.some((row) => + ['starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown'].includes( + this.getWorkerDispatch(row.owner_dispatch_id)?.state ?? '' + ) + ) +} + +/** Every resource whose process incarnation starts with `prefix`, newest first. */ +export function listWorkerTerminalResourcesByIncarnationPrefix( + this: OrchestrationDb, + prefix: string +): WorkerTerminalResourceRow[] { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table is exactly its row shape. + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE substr(process_incarnation, 1, ?) = ? ORDER BY updated_at DESC` + ) + .all(prefix.length, prefix) as WorkerTerminalResourceRow[] +} + export type WorkerTerminalTransferMethods = { findTransferableWorkerTerminalResource: typeof findTransferableWorkerTerminalResource workerTerminalResourceHasIdentityConflict: typeof workerTerminalResourceHasIdentityConflict + hasOpenWorkerDispatchForProcess: typeof hasOpenWorkerDispatchForProcess + listWorkerTerminalResourcesByIncarnationPrefix: typeof listWorkerTerminalResourcesByIncarnationPrefix } export function attachWorkerTerminalTransfer(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { findTransferableWorkerTerminalResource, - workerTerminalResourceHasIdentityConflict + workerTerminalResourceHasIdentityConflict, + hasOpenWorkerDispatchForProcess, + listWorkerTerminalResourcesByIncarnationPrefix }) } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 048fc7732e40..52103cd5322b 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -109,7 +109,7 @@ function harness(options: { resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, host: { - readGateFacts: () => (journal === null ? null : structuredSessionGateFacts(journal)), + readGateFacts: async () => (journal === null ? null : structuredSessionGateFacts(journal)), currentFence: () => 4, send } @@ -329,7 +329,7 @@ describe('forgetting one settled worker', () => { : null }, host: { - readGateFacts: () => structuredSessionGateFacts(journal), + readGateFacts: async () => structuredSessionGateFacts(journal), currentFence: () => 4, send } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 62a555d41191..d3afed4923f0 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -49,8 +49,8 @@ export type StructuredPointerSendOutcome = | { kind: 'unattached' } export type StructuredMailboxPointerHost = { - /** The idle gate, read off the session's full reduced timeline; `null` when it is not attached. */ - readGateFacts: (sessionId: string) => StructuredSessionGateFacts | null + /** The idle gate, read off the session's full reduced timeline; `null` when it cannot be read. */ + readGateFacts: (sessionId: string) => Promise send: (input: { sessionId: string dispatchId: string | null @@ -184,7 +184,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< reservedTypes: ReadonlySet | undefined ): Promise { const sessionId = target.sessionId - const session = this.deps.host.readGateFacts(sessionId) + const session = await this.deps.host.readGateFacts(sessionId) const decision = decideStructuredSessionPointerDelivery({ session }) if (!decision.deliver) { this.retain(mailboxHandle, sessionId, decision.retain, reservedTypes) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index c530a1e84abf..ba28ee431743 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -38,7 +38,7 @@ describe('structured mailbox pointer host', () => { hostRef.current = null }) - it('reads the gate facts from the FULL timeline, never a bounded tail', () => { + it('reads the gate facts from the FULL timeline, never a bounded tail', async () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is @@ -46,22 +46,22 @@ describe('structured mailbox pointer host', () => { // it -- either way folded into work already in flight rather than read as a new instruction. const items = [runningTurn(), ...transcript(500)] hostRef.current = { journalSnapshot: () => ({ items }) } - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ turnRunning: true, awaitingHuman: false }) }) - it('answers null rather than idle when the session cannot be read', () => { + it('answers null rather than idle when the session cannot be read', async () => { // Null retains the pointer; `{turnRunning:false}` would deliver a nudge into a session this // runtime cannot see at all. - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() hostRef.current = { journalSnapshot: () => { throw new Error('agent_session_ownership_unknown') } } - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() }) it('reports an unattached host rather than a rejection when nothing can be sent', async () => { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 907a7bcc0975..d53dc04600e7 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -39,15 +39,16 @@ export function structuredSessionPointerCallerKey(sessionId: string): string { * idle-with-history is the normal steady state of a working agent. Shared so the pointer lane and * group addressing cannot disagree about it. */ -export function readStructuredSessionGateFacts( +export async function readStructuredSessionGateFacts( sessionId: string -): StructuredSessionGateFacts | null { +): Promise { const host = getStructuredAgentSessionHost() if (!host) { return null } try { - return structuredSessionGateFacts(host.journalSnapshot(sessionId).items) + // Opens a conversation the idle sweep closed; that starts no agent. + return structuredSessionGateFacts((await host.journalSnapshot(sessionId)).items) } catch (error) { // Not attached is a retain reason, not a failure; anything else is still unreadable. if ((error as Error)?.message !== AGENT_SESSION_NOT_ATTACHED.code) { diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 93fa58fd6b56..ee3f064c31ad 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -50,6 +50,7 @@ function installHost(options: { items?: AgentJournalRenderItem[] lease?: { runtimeKind: string; claimStatus: string } hasSession?: boolean + tabListed?: boolean }): void { const lease = options.lease ?? { runtimeKind: 'native', claimStatus: 'live' } hostRef.current = { @@ -65,21 +66,45 @@ function installHost(options: { } }, hasSession: () => options.hasSession ?? true, - journalSnapshot: () => ({ items: options.items ?? [idleTurn()] }) + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: options.tabListed ? [SESSION_ID] : [] + }), + journalSnapshot: async () => ({ items: options.items ?? [idleTurn()] }) } } +/** The durable worker-terminal rows group addressing enumerates. */ +const rows: { + terminal_handle: string + pane_key: string + process_incarnation: string + worktree_id: string + host_scope: string +}[] = [] + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: group addressing reads only this one query off the database. +const workerDb = { listWorkerTerminalResourcesByIncarnationPrefix: () => rows } as never + function registerWorker(worktreeId = 'wt_1'): string { const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) structuredWorkerIdentities.register({ handle, sessionId: SESSION_ID, agent: 'codex', - paneKey: mintStructuredWorkerPaneKey(SESSION_ID), + paneKey, processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), worktreeId, hostScope: { kind: 'local', hostId: 'local' } }) + rows.push({ + terminal_handle: handle, + pane_key: paneKey, + process_incarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktree_id: worktreeId, + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) return handle } @@ -88,22 +113,28 @@ const PTY_TERMINAL = { handle: 'term_a', worktreeId: 'wt_1', agentIdentity: 'cla describe('group addressing and structured workers', () => { beforeEach(() => { structuredWorkerIdentities.clear() + rows.length = 0 hostRef.current = null }) it('enumerates a live structured worker as a candidate', () => { const handle = registerWorker() installHost({}) - expect(listAddressableStructuredWorkers()).toEqual([ + expect(listAddressableStructuredWorkers(workerDb)).toEqual([ { handle, worktreeId: 'wt_1', agentIdentity: 'codex' } ]) }) - it('leaves out a worker whose session is not proven live', () => { - // Addressing a settled worker would store mail no lane will ever deliver. - registerWorker() - installHost({ lease: { runtimeKind: 'native', claimStatus: 'live' }, hasSession: false }) - expect(listAddressableStructuredWorkers()).toEqual([]) + it('keeps a worker at rest and leaves out a retired one', () => { + // At rest: its agent was stopped while idle, its chat tab still lists it, and mail starts it. + const handle = registerWorker() + installHost({ lease: { runtimeKind: 'native', claimStatus: 'released' }, tabListed: true }) + expect(listAddressableStructuredWorkers(workerDb)).toEqual([ + { handle, worktreeId: 'wt_1', agentIdentity: 'codex' } + ]) + // Retired: released with its tab gone. Addressing it would store mail no lane delivers. + installHost({ lease: { runtimeKind: 'native', claimStatus: 'released' }, tabListed: false }) + expect(listAddressableStructuredWorkers(workerDb)).toEqual([]) }) it('reaches a structured worker through @all', () => { @@ -112,14 +143,14 @@ describe('group addressing and structured workers', () => { // machinery never ran and the sender got exit 0 with a receipt naming only who did resolve. const handle = registerWorker() installHost({}) - const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers()] + const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)] expect(resolveGroupAddress('@all', 'term_sender', recipients, () => 'idle')).toContain(handle) }) it('reaches a structured worker through @worktree: and @codex, but not @claude', () => { const handle = registerWorker('wt_2') installHost({}) - const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers()] + const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)] expect(resolveGroupAddress('@worktree:wt_2', 'term_sender', recipients, () => 'idle')).toEqual([ handle ]) @@ -129,20 +160,20 @@ describe('group addressing and structured workers', () => { ]) }) - it('reads @idle status off the FULL timeline, never a bounded tail', () => { + it('reads @idle status off the FULL timeline, never a bounded tail', async () => { // The same trap that already cost this branch once: settlement tombstones the lifecycle item // rather than rewriting it, so a long tool-calling turn pushes it arbitrarily far from the // tail and any page-sized read reports a BUSY worker as idle — then `@idle` broadcasts into a // running turn, which Codex refuses outright and Claude queues behind. registerWorker() installHost({ items: [runningTurn(), ...transcript(500)] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('working') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('working') }) - it('answers idle only when no turn is running and no human is awaited', () => { + it('answers idle only when no turn is running and no human is awaited', async () => { registerWorker() installHost({ items: [idleTurn()] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('idle') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('idle') installHost({ items: [ { @@ -156,26 +187,27 @@ describe('group addressing and structured workers', () => { } as unknown as AgentJournalRenderItem ] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('attention') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('attention') }) - it('answers null rather than idle when the session cannot be read', () => { + it('answers null rather than idle when the session cannot be read', async () => { // Unknown must never read as idle, or `@idle` wakes a worker mid-turn. hostRef.current = null - expect(structuredWorkerAgentStatus(SESSION_ID)).toBeNull() + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBeNull() }) }) describe('sendGroupMessage actually composes structured workers in', () => { beforeEach(() => { structuredWorkerIdentities.clear() + rows.length = 0 hostRef.current = null }) /** * Drives the real `sendGroupMessage`, not `resolveGroupAddress`. * - * The suite above hand-composed `[PTY_TERMINAL, ...listAddressableStructuredWorkers()]` itself, + * The suite above hand-composed `[PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)]` itself, * so deleting the composition at the call site left it green — the exact regression the fix * describes could come straight back. This test owns that seam. */ @@ -188,6 +220,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { getCurrentRunForPane: () => undefined, getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], + listWorkerTerminalResourcesByIncarnationPrefix: () => rows, insertMessages: (rows: { to: string }[]) => { inserted.push(...rows) return rows.map((row, index) => ({ id: `m${index}`, to_handle: row.to, type: 'status' })) @@ -197,7 +230,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { // No PTY terminals at all: if the call site does not compose structured workers in, the // group resolves empty and this throws instead of delivering. listTerminals: async () => ({ terminals: [] }), - getAgentStatusForHandle: () => 'idle', + getAgentStatusForHandle: async () => 'idle', getLiveTerminalPaneKey: () => structuredWorkerIdentities.get(handle)!.paneKey, notifyMessageArrived: () => {} } @@ -230,6 +263,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { getActiveDispatchForIdentity: () => ({ run_id: 'run_1' }), getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], + listWorkerTerminalResourcesByIncarnationPrefix: () => rows, listWorkerTerminalResources: () => [ { dispatchId: 'ctx_structured', @@ -257,7 +291,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { listTerminals: async () => ({ terminals: [{ handle: 'term_claude', worktreeId: 'wt_1', agentIdentity: 'claude' }] }), - getAgentStatusForHandle: () => 'idle', + getAgentStatusForHandle: async () => 'idle', getLiveTerminalPaneKey: () => null, getOrchestrationDb: () => db, notifyMessageArrived: () => {} diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 168bd8701188..5eb1157973fd 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -16,8 +16,12 @@ */ import type { TuiAgent } from '../../../shared/tui-agent' -import { observeStructuredWorker, structuredWorkerAgent } from '../structured-worker-authority' -import { structuredWorkerIdentities } from '../structured-worker-identity' +import { structuredWorkerAgent, structuredWorkerOwned } from '../structured-worker-authority' +import { + STRUCTURED_WORKER_INCARNATION_PREFIX, + structuredWorkerIdentityFromRow +} from '../structured-worker-identity' +import type { OrchestrationDb } from './db' import { readStructuredSessionGateFacts } from './structured-mailbox-pointer-host' /** The only facts group addressing reads off a recipient. */ @@ -29,15 +33,26 @@ export type OrchestrationAddressableAgent = { } /** - * Live structured workers of this runtime, as group-address candidates. + * Structured workers this runtime owns, as group-address candidates. * - * Liveness-gated on the same observation the rest of the structured surface uses: a settled or - * handed-off worker is not a recipient, and addressing one would store mail no lane will deliver. + * Read from the durable worker-terminal rows, which outlive a settled dispatch and a restart, and + * gated on ownership — the same answer direct mail routes on — never on liveness: a worker at rest + * is a recipient, and the mail starts it. A retired one is not. */ -export function listAddressableStructuredWorkers(): OrchestrationAddressableAgent[] { - return structuredWorkerIdentities - .list() - .filter((identity) => observeStructuredWorker(identity).status === 'live') +export function listAddressableStructuredWorkers( + db: OrchestrationDb | null +): OrchestrationAddressableAgent[] { + const seen = new Set() + return (db?.listWorkerTerminalResourcesByIncarnationPrefix(STRUCTURED_WORKER_INCARNATION_PREFIX) ?? []) + .flatMap((row) => { + const identity = structuredWorkerIdentityFromRow(row) + // Newest row first: a session is one recipient, under its latest handle. + if (!identity || seen.has(identity.sessionId)) { + return [] + } + seen.add(identity.sessionId) + return structuredWorkerOwned(identity.sessionId) === true ? [identity] : [] + }) .map((identity) => ({ handle: identity.handle, worktreeId: identity.worktreeId, @@ -52,8 +67,8 @@ export function listAddressableStructuredWorkers(): OrchestrationAddressableAgen * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues * behind it. */ -export function structuredWorkerAgentStatus(sessionId: string): string | null { - const facts = readStructuredSessionGateFacts(sessionId) +export async function structuredWorkerAgentStatus(sessionId: string): Promise { + const facts = await readStructuredSessionGateFacts(sessionId) if (!facts) { return null } diff --git a/src/main/runtime/orchestration/structured-worker-journal-page.ts b/src/main/runtime/orchestration/structured-worker-journal-page.ts index 35676aaf52cc..8d260339fa6f 100644 --- a/src/main/runtime/orchestration/structured-worker-journal-page.ts +++ b/src/main/runtime/orchestration/structured-worker-journal-page.ts @@ -16,14 +16,17 @@ export type StructuredJournalPage = { hasOlder: boolean } -/** The newest page of a session's journal, or null when this runtime cannot read it. */ -export function readStructuredJournalPage(sessionId: string): StructuredJournalPage | null { +/** The newest page of a session's journal, or null when this runtime cannot read it. A closed + * conversation is opened for the read; that starts no agent. */ +export async function readStructuredJournalPage( + sessionId: string +): Promise { const host = getStructuredAgentSessionHost() if (!host) { return null } try { - const result = host.history({ + const result = await host.history({ sessionId, direction: 'tail', limit: STRUCTURED_JOURNAL_PAGE_LIMIT diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index d7e894cf54c1..ddae515a7f2c 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -91,7 +91,7 @@ export async function captureWorkerOutputArchive(args: { structuredWorker?: StructuredWorkerIdentity | null }): Promise { if (args.structuredWorker) { - const content = captureStructuredWorkerArchive( + const content = await captureStructuredWorkerArchive( args.structuredWorker, structuredWorkerAgent(args.structuredWorker) ) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 0037d6fc35e9..11762137ba2f 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -63,9 +63,9 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { // Settlement can fail after the journal append. Re-read the authoritative row before asking // the caller to resend, otherwise a retry creates a duplicate turn. try { - const committed = args.host - .journalSnapshot(args.sessionId) - .submissions.find((submission) => submission.clientMessageId === clientMessageId) + const committed = (await args.host.journalSnapshot(args.sessionId)).submissions.find( + (submission) => submission.clientMessageId === clientMessageId + ) if (committed) { return clientMessageId } diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts index a0d0426509b7..b669233c31fb 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts @@ -47,6 +47,7 @@ function installHost(options: { close?: () => Promise setSessionTabVisibility?: () => Promise historyThrows?: boolean + tabListed?: boolean }) { const record = options.record === undefined @@ -63,6 +64,10 @@ function installHost(options: { let closed = false hostRef.current = { deps: { store: { getRecord: () => record } }, + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: options.tabListed ? [IDENTITY.sessionId] : [] + }), hasSession: () => (closed ? false : (options.hasSession ?? true)), setSessionTabVisibility: options.setSessionTabVisibility ?? (async () => {}), close: @@ -84,7 +89,7 @@ describe('structured worker observation', () => { hostRef.current = null }) - it('is unverifiable, never exited, when the host is not installed', () => { + it('is unverifiable, never exited, when the host is not installed', async () => { // Not being able to look is not a death certificate. expect(observeStructuredWorker(IDENTITY)).toEqual({ status: 'unverifiable', @@ -92,12 +97,12 @@ describe('structured worker observation', () => { }) }) - it('is live when the host holds the session under a live native lease', () => { + it('is live when the host holds the session under a live native lease', async () => { installHost({}) expect(observeStructuredWorker(IDENTITY).status).toBe('live') }) - it('is exited only on a released lease with death evidence', () => { + it('is exited only on a released lease with death evidence', async () => { installHost({ claimStatus: 'released', deathEvidence: { kind: 'exit-observed', detail: 'x', observedAt: 1 } @@ -105,7 +110,7 @@ describe('structured worker observation', () => { expect(observeStructuredWorker(IDENTITY).status).toBe('exited') }) - it('is unverifiable when a terminal an older build recorded holds the lease', () => { + it('is unverifiable when a terminal an older build recorded holds the lease', async () => { installHost({ claimStatus: 'conflicted' }) expect(observeStructuredWorker(IDENTITY).status).toBe('unverifiable') }) @@ -127,22 +132,30 @@ describe('structured worker stop', () => { }) }) - it.each([ - { hasSession: false }, - { claimStatus: 'conflicted' }, - { claimStatus: 'released' }, - { record: null } - ])('retains without positive exit evidence: %j', async (options) => { - installHost({ ...options, close: async () => {} }) - const retireStructuredAgentSessionTabFromSnapshot = vi.fn() - const result = await stopStructuredWorker(IDENTITY, 'd1', { - forgetStructuredSessionMail: vi.fn(), - retireStructuredAgentSessionTabFromSnapshot + it('settles a released lease whose stop was unproven: nothing is left running to close', async () => { + // A release that could not prove its stop sent no signal and left the verdict `unverifiable`; + // closing the chat is the user's action, so that bookkeeping does not refuse it. + installHost({ claimStatus: 'released', close: async () => {} }) + await expect(stopStructuredWorker(IDENTITY, 'd1')).resolves.toEqual({ + stopped: true, + closeAttempted: true }) - expect(result).toMatchObject({ stopped: false, closeAttempted: true }) - expect(retireStructuredAgentSessionTabFromSnapshot).not.toHaveBeenCalled() }) + it.each([{ hasSession: false }, { claimStatus: 'conflicted' }, { record: null }])( + 'retains without positive exit evidence: %j', + async (options) => { + installHost({ ...options, close: async () => {} }) + const retireStructuredAgentSessionTabFromSnapshot = vi.fn() + const result = await stopStructuredWorker(IDENTITY, 'd1', { + forgetStructuredSessionMail: vi.fn(), + retireStructuredAgentSessionTabFromSnapshot + }) + expect(result).toMatchObject({ stopped: false, closeAttempted: true }) + expect(retireStructuredAgentSessionTabFromSnapshot).not.toHaveBeenCalled() + } + ) + it('retains when the close throws, and admits the close was issued', async () => { installHost({ close: async () => { @@ -191,9 +204,9 @@ describe('structured worker output', () => { hostRef.current = null }) - it('round-trips the journal through the archive and back out of a released read', () => { + it('round-trips the journal through the archive and back out of a released read', async () => { installHost({}) - const live = readStructuredWorkerJournal({ + const live = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -201,7 +214,7 @@ describe('structured worker output', () => { agent: 'claude' }) expect(live.source).toBe('transcript') - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') hostRef.current = null const archived = readArchivedStructuredJournal({ dispatchId: 'd1', @@ -219,7 +232,7 @@ describe('structured worker output', () => { expect(archived.sourceIdentity).not.toBe(live.sourceIdentity) }) - it('redacts dispatch capabilities from the archived journal', () => { + it('redacts dispatch capabilities from the archived journal', async () => { installHost({ items: [ { @@ -233,13 +246,13 @@ describe('structured worker output', () => { } as unknown as AgentJournalRenderItem ] }) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') expect(JSON.stringify(archive)).not.toContain('dcap_aaa') expect(JSON.stringify(archive)).toContain('[dispatch capability redacted]') }) - it('refuses to read a session the host no longer holds', () => { - expect(() => + it('refuses to read a session the host no longer holds', async () => { + await expect( readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', @@ -247,15 +260,15 @@ describe('structured worker output', () => { liveness: 'live', agent: 'claude' }) - ).toThrow(/not attached/) + ).rejects.toThrow(/not attached/) }) - it('reports an unverifiable worker as unknown, never as running', () => { + it('reports an unverifiable worker as unknown, never as running', async () => { // The `could not look, therefore it is alive` inversion. After a restart the runtime observes // `unverifiable` — no attached provider child in this generation — while the journal is still // readable, and a coordinator reading `running` waits on a worker that may already be gone. installHost({}) - const read = readStructuredWorkerJournal({ + const read = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -266,9 +279,9 @@ describe('structured worker output', () => { expect(read.status.liveness).toBe('unverifiable') }) - it('carries each proven verdict through unchanged', () => { + it('carries each proven verdict through unchanged', async () => { installHost({}) - const live = readStructuredWorkerJournal({ + const live = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -276,7 +289,7 @@ describe('structured worker output', () => { agent: 'claude' }) expect(live.status).toMatchObject({ terminal: 'running', liveness: 'live' }) - const exited = readStructuredWorkerJournal({ + const exited = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'succeeded', @@ -286,9 +299,9 @@ describe('structured worker output', () => { expect(exited.status).toMatchObject({ terminal: 'exited', liveness: 'exited' }) }) - it('states that a settled release is exited', () => { + it('states that a settled release is exited', async () => { installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') const archived = readArchivedStructuredJournal({ dispatchId: 'd1', workerState: 'succeeded', @@ -300,12 +313,12 @@ describe('structured worker output', () => { expect(archived.status).toMatchObject({ terminal: 'exited', liveness: 'exited' }) }) - it('never calls an unproven release exited', () => { + it('never calls an unproven release exited', async () => { // The archive is frozen BEFORE the close. `release_unknown` is the state that records a close // that did NOT land, and a coordinator reading `exited` there starts a replacement worker over // the same worktree while the original provider child may still be attached. installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') for (const releaseState of ['unknown', 'releasing'] as const) { const archived = readArchivedStructuredJournal({ dispatchId: 'd1', @@ -324,7 +337,7 @@ describe('structured worker output', () => { // `readArchivedWorkerOutput`, and the resource row it already holds is the only thing that // knows whether the close landed. installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') const db = { getWorkerTerminalArchive: () => ({ dispatch_id: 'd1', @@ -355,12 +368,12 @@ describe('structured worker output', () => { }) }) - it('refuses a cursor once the tail window has slid past it', () => { + it('refuses a cursor once the tail window has slid past it', async () => { // The cursor is an index into the bounded tail, and `sourceIdentity` was constant for the // worker's life, so a coordinator paging a growing journal resumed at the newest items and // skipped the middle without a word. installHost({ items: ITEMS }) - const first = readStructuredWorkerJournal({ + const first = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -376,7 +389,7 @@ describe('structured worker output', () => { } as unknown as AgentJournalRenderItem ] }) - expect(() => + await expect( readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', @@ -385,7 +398,7 @@ describe('structured worker output', () => { agent: 'claude', cursor: first.cursor }) - ).toThrow(/source changed/i) + ).rejects.toThrow(/source changed/i) }) }) @@ -394,7 +407,7 @@ describe('archiving a structured worker whose journal cannot be read', () => { hostRef.current = null }) - it('settles with an empty, warned archive once the session is PROVEN gone', () => { + it('settles with an empty, warned archive once the session is PROVEN gone', async () => { // Closing the worker's chat tab is a routine user action: it evicts the child and detaches the // journal permanently. Throwing archive_failed there wedged release on evidence that could // never arrive, leaving worker-abandon as the only way out. @@ -403,7 +416,7 @@ describe('archiving a structured worker whose journal cannot be read', () => { claimStatus: 'released', deathEvidence: { kind: 'exit-observed', detail: 'surface released', observedAt: 1 } }) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') expect(archive.messages).toEqual([]) expect(archive.processIncarnation).toBe(IDENTITY.processIncarnation) expect(archive.warnings).toContain( @@ -411,12 +424,24 @@ describe('archiving a structured worker whose journal cannot be read', () => { ) }) - it('still retains when the journal is unreadable but nothing proves the child is gone', () => { + it('retains a worker at rest: released, but its chat tab still lists it', async () => { + // Released is not retired now — the idle sweep releases a quiet worker's lease — so an owned + // worker whose journal cannot be read keeps its evidence for a later archive. + installHost({ + historyThrows: true, + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'idle stop', observedAt: 1 }, + tabListed: true + }) + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) + }) + + it('still retains when the journal is unreadable but nothing proves the child is gone', async () => { installHost({ historyThrows: true }) - expect(() => captureStructuredWorkerArchive(IDENTITY, 'claude')).toThrow(/retained/) + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) }) - it('still retains when there is no host to look with', () => { - expect(() => captureStructuredWorkerArchive(IDENTITY, 'claude')).toThrow(/retained/) + it('still retains when there is no host to look with', async () => { + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index 13d3ce1dbce1..c15fafaf1082 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -37,6 +37,7 @@ import { observeStructuredWorker, resolveStructuredWorkerIdentity, structuredWorkerAgent, + structuredWorkerOwned, structuredWorkerTerminalState, type StructuredWorkerObservation } from '../../structured-worker-authority' @@ -45,7 +46,7 @@ import type { WorkerTerminalReleaseState } from '../../orchestration/worker-term import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' -export { observeStructuredWorker, type StructuredWorkerObservation } +export { observeStructuredWorker, structuredWorkerOwned, type StructuredWorkerObservation } /** The structured worker behind a dispatch, or null when a PTY worker owns it. */ export function resolveStructuredWorkerForDispatch( @@ -89,7 +90,7 @@ export async function stopStructuredWorker( } /** The structured half of `worker-read`, or null when a PTY worker owns the dispatch. */ -export function readStructuredWorkerOutput(args: { +export async function readStructuredWorkerOutput(args: { db: OrchestrationDb dispatchId: string workerState: string @@ -98,7 +99,7 @@ export function readStructuredWorkerOutput(args: { source?: 'auto' | 'transcript' | 'terminal' cursor?: string | number limit?: number -}): OrchestrationWorkerReadTranscriptResult | null { +}): Promise { const identity = resolveStructuredWorkerForDispatch(args.db, args.dispatchId) if (!identity) { return null @@ -123,7 +124,7 @@ export function readStructuredWorkerOutput(args: { } /** Journal page in the shape `worker-read --source transcript` already serves. */ -export function readStructuredWorkerJournal(args: { +export async function readStructuredWorkerJournal(args: { identity: StructuredWorkerIdentity dispatchId: string workerState: string @@ -131,8 +132,8 @@ export function readStructuredWorkerJournal(args: { agent: AgentType cursor?: string | number limit?: number -}): OrchestrationWorkerReadTranscriptResult { - const page = readStructuredJournalPage(args.identity.sessionId) +}): Promise { + const page = await readStructuredJournalPage(args.identity.sessionId) if (!page) { throw new OrchestrationError( 'transcript_required', @@ -213,11 +214,13 @@ function structuredJournalPrefixIdentity(args: { } /** Freezes the journal before the session is closed, so a released worker is still readable. */ -export function captureStructuredWorkerArchive( +export async function captureStructuredWorkerArchive( identity: StructuredWorkerIdentity, agent: AgentType -): WorkerStructuredJournalArchive { - const page = readStructuredJournalPage(identity.sessionId) +): Promise { + // Opens a conversation at rest or one the idle sweep closed, so a resting worker's journal is + // still preserved. + const page = await readStructuredJournalPage(identity.sessionId) if (page) { return buildStructuredJournalArchive({ agent, @@ -232,10 +235,9 @@ export function captureStructuredWorkerArchive( // the worker's chat tab is a routine user action that does exactly that, so throwing there wedges // release on evidence that can never come and leaves `worker-abandon` as the only exit. // - // `exited` is the only verdict that qualifies: it needs a released lease WITH death evidence. - // `unverifiable` — no host installed, a lease handed to a TUI owner — means we could not look, - // and retaining is still right. - if (observeStructuredWorker(identity).status !== 'exited') { + // Only a worker this runtime no longer owns qualifies — released with its chat tab gone. An owned + // one, running or at rest, keeps its journal, and so does one we could not look at. + if (structuredWorkerOwned(identity.sessionId) !== false) { throw new OrchestrationError( 'archive_failed', 'Output could not be preserved for this structured worker; the session was retained.' 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 c8ebb9eab63e..a38d5a77c471 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 @@ -14,37 +14,34 @@ vi.mock('./structured-agent-session-create', () => ({ const { createStructuredWorkerSession, releaseStructuredWorkerSession, - sendStructuredWorkerPreamble, - structuredWorkerHoldId + sendStructuredWorkerPreamble } = await import('./orchestration-structured-worker-session') const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/worker-topology') const { structuredWorkerIdentities } = await import('../../structured-worker-identity') const { structuredWorkerChildIdentityEnv } = await import('../../structured-worker-child-identity-env') -function installHost() { - const hold = vi.fn(async () => {}) - const release = vi.fn() +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a host stub carrying only the members the worker start reaches. +function installHost(location = { executionHostId: 'local', wslDistro: null as string | null }) { const dispose = vi.fn() + const subscribe = vi.fn(async () => dispose) hostRef.current = { setSessionTabVisibility: async () => {}, close: async () => {}, deps: { store: { getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, + location, lease: { runtimeFence: 2, runtimeKind: 'native', claimStatus: 'live' } }) } }, - hold, - release, - subscribe: () => dispose + subscribe } - return { hold, release, dispose } + return { subscribe, dispose } } -describe('structured worker session hold', () => { +describe('structured worker session', () => { beforeEach(() => { structuredWorkerIdentities.clear() createSpy.mockReset() @@ -54,8 +51,8 @@ describe('structured worker session hold', () => { })) }) - it('takes a resume-capable hold at start and releases it only on settlement', async () => { - const { hold, release, dispose } = installHost() + it('binds only a redrive subscription at start, and settlement drops it', async () => { + const { subscribe, dispose } = installHost() const created = await createStructuredWorkerSession({ runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, worktreeId: 'wt_1', @@ -63,18 +60,19 @@ describe('structured worker session hold', () => { dispatchId: 'd1', onJournalActivity: () => {} }) - // Without the hold, the release clock evicts the provider child 15s after a user closes the - // worker's chat tab, killing an idle worker mid-dispatch. - expect(hold).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) - expect(release).not.toHaveBeenCalled() + // No hold: while the dispatch is open the idle sweep reads it from the orchestration database. + expect(hostRef.current).not.toHaveProperty('hold') + expect(subscribe).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: created.identity.sessionId }) + ) + expect(dispose).not.toHaveBeenCalled() releaseStructuredWorkerSession('d1') - expect(release).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) expect(dispose).toHaveBeenCalledTimes(1) expect(structuredWorkerIdentities.get(created.identity.handle)).toBeNull() - // A second settlement is a no-op rather than a second release of the same holder. + // A second settlement is a no-op. releaseStructuredWorkerSession('d1') - expect(release).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) }) it('registers the identity BEFORE the session is created, so the child gets the handle', async () => { @@ -100,8 +98,8 @@ describe('structured worker session hold', () => { }) it('forgets the identity and discards the session when the start fails', async () => { - const { hold } = installHost() - hold.mockRejectedValueOnce(new Error('hold refused')) + // A session that resolves outside the local host is not a worker this runtime can own. + installHost({ executionHostId: 'local', wslDistro: 'Ubuntu' }) const closed: string[] = [] ;(hostRef.current as { close: (id: string) => Promise }).close = async (id) => { closed.push(id) @@ -114,7 +112,7 @@ describe('structured worker session hold', () => { dispatchId: 'd_fail', onJournalActivity: () => {} }) - ).rejects.toThrow('hold refused') + ).rejects.toThrow(/local execution host outside WSL/) // Neither a live provider child nor a registry entry may outlive the failed start. expect(closed).toHaveLength(1) expect(structuredWorkerIdentities.getBySessionId(closed[0]!)).toBeNull() @@ -128,7 +126,7 @@ describe('structured worker session hold', () => { } // `commit` answers this after `attach` SUCCEEDED and only the tab publish failed, so the // provider child is live. Reading it as "refused, nothing created" strands that child with no - // hold and no binding, and nothing else in the runtime ever retires it. + // binding, and nothing else in the runtime ever retires it. createSpy.mockImplementation(async () => ({ ok: false, refusal: { @@ -228,6 +226,7 @@ describe('structured worker dispatch preamble', () => { submission: Record, delivered?: Record ) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a host stub carrying only the members the worker start reaches. return { deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }), 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 3ac972138697..92cf3d7970c6 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -1,13 +1,13 @@ /** - * Starting, holding and retiring a worker that IS a structured agent session. + * Starting and retiring a worker that IS a structured agent session. * * Three things make this different from the PTY worker path, and all three live here: * * - The session is created directly as structured, so readiness is the attach returning ok. There * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. - * - A structured session's provider child is evicted 15s after its last HOLDER leaves, and holds - * come only from bound surfaces. A dispatched worker parked on mail is exactly that state, so - * the dispatch takes its own resume-capable hold and keeps it until the worker settles. + * - Nothing here keeps its agent running. The idle sweep leaves it running while its dispatch is + * open, reading that from the orchestration database; once the dispatch settles the agent rests + * like any chat's, and the next mail starts it. * - The dispatch preamble is a turn, not keystrokes. */ @@ -39,23 +39,16 @@ import { createStructuredAgentSessionForWorktree } from './structured-agent-sess type StructuredWorkerBinding = { sessionId: string handle: string - holderId: string disposeSubscription: () => void } const bindingsByDispatchId = new Map() -export function structuredWorkerHoldId(dispatchId: string): string { - return `orchestration:dispatch:${dispatchId}` -} - /** - * Drops the dispatch's hold, its redrive subscription and its parked mail; the release clock takes - * it from here. + * Drops the dispatch's redrive subscription, its registry entry and its parked mail. * - * EVERY settlement has to reach this — stop, release AND abandon. A surviving hold does not just - * leak: it keeps the provider child un-evictable for the life of the app, and makes host crash - * recovery respawn a child for a worker that was settled long ago. + * EVERY settlement has to reach this — stop, release AND abandon. A surviving subscription keeps + * nudging a session no dispatch owns. */ export function releaseStructuredWorkerSession( dispatchId: string, @@ -69,11 +62,6 @@ export function releaseStructuredWorkerSession( binding.disposeSubscription() structuredWorkerIdentities.forget(binding.handle) runtime?.forgetStructuredSessionMail?.(binding.sessionId) - try { - getStructuredAgentSessionHost()?.release(binding.sessionId, binding.holderId) - } catch (error) { - console.warn('[orchestration] structured worker hold release failed', dispatchId, error) - } } export async function createStructuredWorkerSession(args: { @@ -93,7 +81,7 @@ export async function createStructuredWorkerSession(args: { // resolves to whatever single leaf sits in the worktree — by default the COORDINATOR's pane. // // The scope is provisionally local; the record's own location is asserted local below, and a - // session that resolves anywhere else never reaches a hold. + // session that resolves anywhere else is discarded. const identity = structuredWorkerIdentities.register({ handle: mintStructuredWorkerHandle(), sessionId, @@ -142,13 +130,10 @@ export async function createStructuredWorkerSession(args: { 'A structured worker must run on the local execution host outside WSL.' ) } - const holderId = structuredWorkerHoldId(args.dispatchId) - await host.hold(sessionId, holderId) - const disposeSubscription = subscribeForRedrive(host, sessionId, args.onJournalActivity) + const disposeSubscription = await subscribeForRedrive(host, sessionId, args.onJournalActivity) bindingsByDispatchId.set(args.dispatchId, { sessionId, handle: identity.handle, - holderId, disposeSubscription }) return { identity, host } @@ -316,17 +301,17 @@ const REDRIVE_MAX_WAIT_MS = 2_000 * idle worker — that is `deliverForHandle`, called when the message is enqueued and untouched * here. This is only the retry for mail already parked because the worker was busy. */ -function subscribeForRedrive( +async function subscribeForRedrive( host: StructuredAgentSessionHost, sessionId: string, onJournalActivity: (sessionId: string) => void -): () => void { +): Promise<() => void> { const coalescer = createKeyedTrailingEdgeCoalescer(onJournalActivity, { flushMs: REDRIVE_FLUSH_MS, maxWaitMs: REDRIVE_MAX_WAIT_MS }) try { - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: `orchestration:redrive:${sessionId}`, sessionId, emit: (event) => { @@ -336,7 +321,7 @@ function subscribeForRedrive( } }) // Disposal drops the pending timer rather than flushing it: every settlement reaches here, and - // a redrive that fires after the hold is gone would nudge a session no dispatch owns. + // a redrive that fires after it would nudge a session no dispatch owns. return () => { coalescer.dispose() unsubscribe() diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index 48ffe07d5d09..199e43fae7b3 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -254,10 +254,14 @@ describe('a worker cannot tell which mode it is running in', () => { source: 'terminal' }) - expect(read).toThrow(/has no terminal output/) + const refusal = await read().then( + () => '', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) + expect(refusal).toMatch(/has no terminal output/) // The refusal names a source that works instead of naming the worker's kind. - expect(read).toThrow(/--source auto or --source transcript/) - expect(read).not.toThrow(/structured/i) + expect(refusal).toMatch(/--source auto or --source transcript/) + expect(refusal).not.toMatch(/structured/i) }) it('never claims a structured worker was checked for a human-answerable prompt', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts index b076be065931..62d2b5da4950 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts @@ -69,7 +69,7 @@ describe('orchestration.send group addresses', () => { return terminal ? `${terminal.tabId}:${terminal.leafId}` : null }) vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( - (handle: string) => agentStatuses?.[handle] ?? null + async (handle: string) => agentStatuses?.[handle] ?? null ) } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts index d5ee366a40f4..497910643ad3 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts @@ -145,7 +145,7 @@ export async function sendGroupMessage(args: { const { terminals } = await runtime.listTerminals(undefined, undefined, { includeVisualLayouts: false }) - agents = [...terminals, ...listAddressableStructuredWorkers()] + agents = [...terminals, ...listAddressableStructuredWorkers(db)] } // Revalidate after discovery before selecting recipients or writing mail. revalidateLegacyCoordinator?.() @@ -164,8 +164,23 @@ export async function sendGroupMessage(args: { agents, warnings: groupWarnings }) - const handles = resolveGroupAddress(groupAddress, from, candidates, (handle: string) => - runtime.getAgentStatusForHandle(handle) + // Read up front: a structured worker's status comes from its journal, which may need opening. + const statuses = + groupAddress.toLowerCase() === '@idle' + ? new Map( + await Promise.all( + candidates.map( + async (candidate) => + [candidate.handle, await runtime.getAgentStatusForHandle(candidate.handle)] as const + ) + ) + ) + : new Map() + const handles = resolveGroupAddress( + groupAddress, + from, + candidates, + (handle: string) => statuses.get(handle) ?? null ) if (handles.length === 0) { // Preserve the recovery addresses even when every worker was skipped. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 5e8babf3c5e1..7f150493f150 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -143,7 +143,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ `Worker Dispatch ${params.dispatch} no longer resolves to its exact process.` ) } - const structured = readStructuredWorkerOutput({ + const structured = await readStructuredWorkerOutput({ db, dispatchId: params.dispatch, workerState: worker?.state ?? 'unsupervised', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index 83b3d020f4ca..6f654c358172 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -7,7 +7,8 @@ import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestr import { projectWorkerFleet } from './worker-list-projection' import { observeStructuredWorker, - resolveStructuredWorkerForDispatch + resolveStructuredWorkerForDispatch, + structuredWorkerOwned } from '../../orchestration-structured-worker-lifecycle' import type { DispatchContextRow, @@ -27,6 +28,9 @@ export async function inspectWorkerTerminal( reason?: string /** Set only on a proven-exact worker parked on a prompt that needs a human. */ agentWait?: RuntimeTerminalInteractiveWait | null + /** Structured workers only: whether mail still reaches it — at rest included, since the mail + * starts it. Absent when ownership cannot be read. `status` stays the process verdict. */ + addressable?: boolean }> { const worker = db.getWorkerDispatch(dispatchId) const terminalHandle = @@ -49,11 +53,13 @@ export async function inspectWorkerTerminal( processIncarnation: structured.processIncarnation }) const observation = observeStructuredWorker(structured) + const addressable = structuredWorkerOwned(structured.sessionId) return { terminal: null, exact, status: exact ? observation.status : 'identity_changed', - ...(exact && observation.reason ? { reason: observation.reason } : {}) + ...(exact && observation.reason ? { reason: observation.reason } : {}), + ...(exact && addressable !== null ? { addressable } : {}) } } const terminal = await runtime.showTerminal(terminalHandle).catch(() => null) @@ -119,7 +125,8 @@ export function exposeObservation(observation: Awaited { it.each(CLEANUP_METHODS)( 'still serves $method after the host setting is turned off', - async ({ method, params, hostCall }) => { - const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF) + async (entry) => { + const response = await call(entry.method, entry.params, STRUCTURED_CLIENT, SETTING_OFF) expect(response).toMatchObject({ ok: true }) - // `unsubscribe` retires runtime-owned subscriptions rather than calling the host, so its - // result payload is the observable effect. - if (hostCall === 'unsubscribe') { - expect(response).toMatchObject({ result: { unsubscribed: true } }) + // `unsubscribe` retires runtime-owned subscriptions and `release` is a no-op, so neither + // calls the host: the result payload is the observable effect. + if (entry.hostCall === null) { + expect(response).toMatchObject({ result: entry.result }) } else { - expect(hostCalls[hostCall]).toHaveBeenCalled() + expect(hostCalls[entry.hostCall]).toHaveBeenCalled() } } ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts new file mode 100644 index 000000000000..412abc8bb22e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -0,0 +1,315 @@ +// A chat at rest, through the RPC surface a client actually calls: opening it starts nothing, what +// it can answer without an agent it answers, and the first send is what starts one. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { openJournalDatabase } from '../../../native-chat/agent-session-journal/journal-database' +import { + journalDatabaseFile, + journalDirectoryFor +} from '../../../native-chat/agent-session-journal/journal-paths' +import { + HOST_TEST_LOCATION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' +import { discardStructuredWorkerSession } from './orchestration-structured-worker-session' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: 'connection-1' +} + +let rig: RestTestRig +let dispatcher: RpcDispatcher +let requests = 0 + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + requests += 1 + await dispatcher.dispatchStreaming( + { id: `request-${requests}`, authToken: 'token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + return replies +} + +/** A chat that once ran, reopened by a fresh host: nothing of it is in memory. */ +async function restingChat(): Promise { + await foundRestTestChat(rig) + await rig.store.replaceSessionOptions({ + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'gpt-saved', effort: 'high' }, + now: rig.clock.now + }) + await rig.restart() + setStructuredAgentSessionHost(rig.host) + rig.adapter.acquire.mockClear() + rig.adapter.dispatch.mockClear() + rig.adapter.readOptions.mockClear() +} + +beforeEach(async () => { + requests = 0 + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. + ({ experimentalStructuredNativeChat: true }) as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +describe('opening a chat at rest (P2-01)', () => { + it('reads, subscribes and answers everything without starting an agent', async () => { + await restingChat() + expect(rig.host.hasSession(SESSION)).toBe(false) + + const [history] = await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }) + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + const [options] = await call('agentSession.options', { sessionId: SESSION }) + const [commands] = await call('agentSession.commands', { sessionId: SESSION }) + const [outline] = await call('agentSession.conversationOutline', { sessionId: SESSION }) + const [status] = await call('agentSession.handoffStatus', { sessionId: SESSION }) + const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'pane' }) + + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(history).toMatchObject({ ok: true, result: { ok: true } }) + const snapshot = frames.find((frame) => frame.ok && 'result' in frame) + expect(snapshot).toMatchObject({ result: { type: 'snapshot', commands: null } }) + expect(options).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-saved', effort: 'high' } } + }) + // Absent, never an empty list: the composer keeps its own menu. + expect(commands).toMatchObject({ ok: true, result: {} }) + expect(commands?.ok && 'result' in commands && commands.result).not.toHaveProperty( + 'commands', + [] + ) + expect(outline).toMatchObject({ ok: true }) + expect(status).toMatchObject({ ok: true }) + expect(held).toMatchObject({ ok: true, result: { held: true } }) + }) + + it('starts the agent on the first send (P2-01)', async () => { + await restingChat() + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + const sent = await rig.host.send(CALLER, restTestSend('wake up', fence)) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + }) +}) + +describe('the accessor', () => { + it('opens a closed conversation once, however many readers arrive together (P2-02)', async () => { + // Written by the provider alone, so nothing but the readers below ever opens it here. + const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + expect(attached.ok).toBe(true) + rig.adapter.acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, + hostTestMessage('from the provider') + ) + await rig.host.flushStreamedEvents(SESSION) + await rig.restart() + setStructuredAgentSessionHost(rig.host) + rig.adapter.acquire.mockClear() + const open = vi.spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private open, spied to count journal opens. + Reflect.get(rig.host, 'conversationDelivery') as { open: (id: string) => Promise }, + 'open' + ) + await Promise.all([ + ...Array.from({ length: 5 }, () => + call('agentSession.history', { sessionId: SESSION, direction: 'tail' }) + ), + call('agentSession.subscribe', { sessionId: SESSION }) + ]) + const openedJournals = new Set( + await Promise.all(open.mock.results.map((result) => result.value)) + ) + expect(openedJournals.size).toBe(1) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) + + it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { + await foundRestTestChat(rig) + await rig.host.flushAllStreamedEvents() + const directory = journalDirectoryFor(rig.root, { + workspaceId: HOST_TEST_LOCATION.workspaceId, + sessionId: SESSION + }) + // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. + const opened = openJournalDatabase(journalDatabaseFile(directory)) + try { + opened.db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) + } finally { + opened.db.close() + } + await rig.restart() + setStructuredAgentSessionHost(rig.host) + + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + expect(frames.some((frame) => !frame.ok)).toBe(false) + expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('after the repair', fence))).ok).toBe(true) + }) + + it('subscribes an old mobile client that holds first even when no agent can start (P2-06)', async () => { + await restingChat() + rig.adapter.acquire.mockRejectedValue(new Error('auth expired')) + + const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'mobile' }) + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + expect(held).toMatchObject({ ok: true }) + expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) +}) + +describe('options at rest', () => { + it('records a pick as intent and replays it at the next start (P2-16)', async () => { + await restingChat() + const fields = { key: 'model', value: 'gpt-picked' } + const [picked] = await call('agentSession.setOption', { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + expect(picked).toMatchObject({ ok: true, result: { ok: true } }) + expect(rig.store.getRecord(SESSION)?.options).toMatchObject({ model: 'gpt-picked' }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await rig.host.send(CALLER, restTestSend('use the new model', fence)) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + expect(rig.adapter.acquire.mock.calls[0]?.[0]).toMatchObject({ + options: expect.objectContaining({ model: 'gpt-picked' }) + }) + }) + + it('answers the provider-level features of a chat at rest (P2-17)', async () => { + await restingChat() + Object.assign(rig.host.deps.adapter, { + supportsThreadGoal: (_id: string, agent?: string) => agent === 'codex', + recordsContextUsage: (_id: string, agent?: string) => agent === 'claude', + rewindSupport: (_id: string, agent?: string) => + agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } + }) + const [options] = await call('agentSession.options', { sessionId: SESSION }) + expect(options).toMatchObject({ + ok: true, + result: { threadGoal: { current: null }, rewind: { supported: true } } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.readOptions).not.toHaveBeenCalled() + }) +}) + +describe('an agent exit', () => { + it('is shown, not respawned; the next send starts the agent (P2-18)', async () => { + await foundRestTestChat(rig) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private map, read for the child's fence and acquisition. + const sessions = Reflect.get(rig.host, 'sessions') as Map< + string, + { child: { fence: number; generation: string } | null } + > + const running = sessions.get(SESSION)!.child! + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + await rig.host.send(CALLER, restTestSend('again', fence)) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + }) +}) + +describe('every close withdraws what is queued (P2-29)', () => { + it.each([ + ['the close RPC', async () => void (await call('agentSession.close', { sessionId: SESSION }))], + ['the runtime chat close', async () => void (await closeStructuredAgentSessionChild(SESSION))], + [ + 'a discarded worker', + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the discard reads only this runtime member. + discardStructuredWorkerSession(SESSION, { + retireStructuredAgentSessionTabFromSnapshot: () => undefined + } as never) + ] + ])('%s rejects a message accepted before any start, and starts nothing', async (_, close) => { + await restingChat() + // The delivery loop has not reached its first start yet. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private delivery loop, held before its first start. + const loop = (Reflect.get(rig.host, 'conversationDelivery') as { loop: { wake: () => void } }) + .loop + vi.spyOn(loop, 'wake').mockImplementation(() => undefined) + const reader: unknown[] = [] + await rig.host.subscribe({ + id: 'reader', + sessionId: SESSION, + emit: (event) => reader.push(event) + }) + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('closed before it went', fence))).ok).toBe( + true + ) + + await close() + await vi.waitFor(() => + expect(JSON.stringify(reader)).toContain('provider_closed_before_delivery') + ) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index 2b62b00525e8..8a9e2267a5a8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, @@ -63,7 +63,6 @@ describe('background-task stop capability at the RPC boundary', () => { it.each(['snapshot', 'batch', 'reset'] as const)( 'gates the %s stream without changing the provider state', async (type) => { - hostCalls.hold = vi.fn(async () => undefined) hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { const base = { sessionId: SESSION, fence: 1, backgroundTasks: TASKS } if (type === 'batch') { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts index 99bf60da727d..08ecc212ec1b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts @@ -7,7 +7,7 @@ import { readAgentSessionConversationOutline } from '../../../native-chat/agent-session-wire/agent-session-conversation-outline' import { defineMethod } from '../core' -import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ @@ -15,6 +15,8 @@ export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ name: 'agentSession.conversationOutline', params: OptionsParams, handler: async (params, ctx) => - readAgentSessionConversationOutline(requireHost(ctx).journalSnapshot(params.sessionId)) + readAgentSessionConversationOutline( + await (await requireInstalledStructuredHost(ctx)).journalSnapshot(params.sessionId) + ) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 72c52c888fd3..7b388f5eca22 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -21,15 +21,18 @@ export const CLEANUP_METHODS = [ params: { envelope: envelope(), turnId: 'turn-1' }, hostCall: 'cancel' }, + // A no-op kept for older clients: it answers without reaching the host. { method: 'agentSession.release', params: { sessionId: SESSION, holderId: 'surface-1' }, - hostCall: 'release' + hostCall: null, + result: { released: true } }, { method: 'agentSession.unsubscribe', params: { sessionId: SESSION }, - hostCall: 'unsubscribe' + hostCall: null, + result: { unsubscribed: true } } ] as const diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 95e3e3d6eee7..f3ad78c5c5cc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -73,10 +73,9 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe return host } -/** Builds the host for the calls that address a session by durable record rather than by live - * state: attach, which is the only way a session comes into being, plus hold and reveal, which - * each reach for a record on disk this process may not have opened yet. Every other method - * addresses a session that must already be attached, and correctly reports absent when none is. */ +/** Builds the host for a call that may be the first this process sees. Every session is addressed + * by its durable record — a read opens a conversation at rest — so each call that reaches for one + * may meet a host nothing has built yet. */ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. @@ -89,6 +88,14 @@ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { + await ensureStructuredHostInstalled(ctx) + return requireStructuredHost(ctx) +} + /** Mirrors the existing agent-session host-authority derivation so one client * gets one operation namespace across both surfaces. */ export function structuredCallerFor(ctx: RpcContext): StructuredAgentSessionCaller { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index c3099d843ced..02099c9ddcf3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -1,8 +1,8 @@ -// The wire half of a session's lifetime: who takes a hold, and what happens when they vanish. +// `agentSession.hold` / `release` are kept answering for clients that still send them, and do +// nothing else: a view never starts or keeps an agent. // -// Run against the REAL subscription registry rather than a stub, because the backstop being tested -// IS that registry's connection sweep — a stubbed `registerSubscriptionCleanup` would prove that -// the handler called a function, which is not the claim. +// Run against the REAL subscription registry rather than a stub, because the claim includes that a +// connection closing runs nothing hold-related — a stubbed registry could not show that. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -26,7 +26,6 @@ import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' const CONNECTION = 'connection-1' -const GRACE_MS = 5 const CLIENT = { clientId: 'device-1', clientKind: 'runtime' as const, @@ -90,7 +89,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - releaseGraceMs: GRACE_MS, now: () => NOW }) setStructuredAgentSessionHost(host) @@ -114,186 +112,85 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a client that holds a session', () => { - it('keeps the provider child while the hold stands', async () => { +describe('the hold surface, for clients that still call it', () => { + it('answers a hold without starting an agent or registering a cleanup', async () => { + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') + const acquiresBefore = acquire.mock.calls.length + expect( await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) + ).toMatchObject({ ok: true, result: { held: true } }) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) + expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(registered).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('releases it when the client says so', async () => { + it('answers a release without stopping anything, and a connection close runs nothing', async () => { await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) expect( await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) + ).toMatchObject({ ok: true, result: { released: true } }) + runtime.cleanupSubscriptionsForConnection(CONNECTION) + await new Promise((resolve) => setTimeout(resolve, 20)) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) }) - it('releases its hold and cleanup after the setting is disabled', async () => { - const release = vi.spyOn(host, 'release') - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + it('refuses a hold once the setting is off, and still answers a release', async () => { structuredNativeChatEnabled = false + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: false }) expect( await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) - const releaseCallsAfterRpc = release.mock.calls.length - runtime.cleanupSubscriptionsForConnection(CONNECTION) - - expect(releaseCallsAfterRpc).toBe(2) - expect(release).toHaveBeenCalledTimes(releaseCallsAfterRpc) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('does not report success when no provider child can be acquired', async () => { - const response = await call('agentSession.hold', { - sessionId: 'session-missing', - holderId: 'chat-missing' - }) - - expect(response).toMatchObject({ - ok: false, - error: { code: 'agent_session_identity_required' } - }) - expect(host.isHeld('session-missing')).toBe(false) + ).toMatchObject({ ok: true, result: { released: true } }) + expect(closeSession).not.toHaveBeenCalled() }) -}) -describe('a client that disappears without cleanup', () => { - it('shares one child with a same-ID replacement that arrives while the first hold resumes', async () => { + it('answers a hold even when no agent could be started', async () => { await host.close(SESSION) - await host.restoreReadableSessions() - closeSession.mockClear() - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - try { - const params = { sessionId: SESSION, holderId: 'same-chat' } - const first = call('agentSession.hold', params) - await entered.promise - // Re-registering the cleanup id released the first hold; the replacement waits its turn - // behind the first hold's attach and finds the child it made. - const replacement = call('agentSession.hold', params) - gate.resolve() - - expect(await first).toMatchObject({ ok: true }) - expect(await replacement).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() - expect(host.isHeld(SESSION)).toBe(true) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 4)) - expect(closeSession).not.toHaveBeenCalled() + acquire.mockRejectedValue(new Error('provider unavailable')) + const acquiresBefore = acquire.mock.calls.length - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - } finally { - gate.resolve() - } + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true, result: { held: true } }) + expect(acquire.mock.calls.length).toBe(acquiresBefore) }) +}) - it('lets a same-ID replacement make its own attempt when the first hold fails to acquire', async () => { +describe('a stream', () => { + it('reads a closed conversation without starting its agent', async () => { await host.close(SESSION) - await host.restoreReadableSessions() - closeSession.mockClear() - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - acquire.mockImplementationOnce(async () => { - entered.resolve() - await gate.promise - throw new Error('acquisition failed') - }) - try { - const params = { sessionId: SESSION, holderId: 'same-chat' } - const first = call('agentSession.hold', params) - await entered.promise - const replacement = call('agentSession.hold', params) - gate.resolve() - - expect(await first).toMatchObject({ ok: false }) - // One attempt per hold: the replacement's own succeeds, and the holder it re-took stands. - const replaced = await replacement - expect(replaced, JSON.stringify(replaced)).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(host.isHeld(SESSION)).toBe(true) - expect(closeSession).not.toHaveBeenCalled() - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - } finally { - gate.resolve() - } - }) - - it('still releases the session when its transport closes', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) + expect(host.hasSession(SESSION)).toBe(false) + const acquiresBefore = acquire.mock.calls.length + const frames: unknown[] = [] - it('does not release a hold another connection is still holding', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) await dispatcher.dispatchStreaming( { - id: 'request-other', + id: 'request-subscribe', authToken: 'token', - method: 'agentSession.hold', - params: { sessionId: SESSION, holderId: 'chat-1' } + method: 'agentSession.subscribe', + params: { sessionId: SESSION } }, - () => {}, - { ...CLIENT, clientId: 'device-2', connectionId: 'connection-2' } + (raw) => frames.push(JSON.parse(raw)), + CLIENT ) - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('does not let an old connection sweep release its same-document replacement', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - await dispatcher.dispatchStreaming( - { - id: 'request-replacement', - authToken: 'token', - method: 'agentSession.hold', - params: { sessionId: SESSION, holderId: 'chat-1' } - }, - () => {}, - { ...CLIENT, connectionId: 'connection-2' } + expect(frames).toContainEqual( + expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) ) - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - - runtime.cleanupSubscriptionsForConnection('connection-2') - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('releases a desktop subscription when its renderer transport dies', async () => { + it('keeps nothing alive when its transport dies', async () => { const transport = new AbortController() await dispatcher.dispatchStreaming( { @@ -310,56 +207,12 @@ describe('a client that disappears without cleanup', () => { clientCapabilities: CLIENT.clientCapabilities } ) - expect(host.isHeld(SESSION)).toBe(true) transport.abort() + await new Promise((resolve) => setTimeout(resolve, 20)) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('unsubscribes and releases stream retention after the setting is disabled', async () => { - await dispatcher.dispatchStreaming( - { - id: 'stream-disabled-cleanup', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - CLIENT - ) - expect(host.isHeld(SESSION)).toBe(true) - structuredNativeChatEnabled = false - - expect( - await call('agentSession.unsubscribe', { - sessionId: SESSION, - subscriptionId: 'stream-disabled-cleanup' - }) - ).toMatchObject({ ok: true }) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('does not let a stream alone resume a released session', async () => { - await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - await host.restoreReadableSessions() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - - await dispatcher.dispatchStreaming( - { - id: 'request-subscribe', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - CLIENT - ) - - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + // The idle sweep, not a departing reader, is what stops an agent. + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index 54753fcb8894..e1975f1c7255 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -1,64 +1,31 @@ -// `agentSession.hold` / `agentSession.release` — a surface saying it is bound to a session. +// `agentSession.hold` / `agentSession.release` — kept answering for clients that still send them. // -// The holder identity is scoped to the CONNECTION, not taken from the client verbatim: two clients -// are free to name their surfaces the same thing, and a hold that collides is one that a stranger -// can release. -// -// The registered cleanup is the backstop, and the ONLY thing that covers a client which vanishes — -// a paired client that disconnects mid-turn never gets to send its release. Registering it before taking -// the hold is deliberate: re-registering an id runs the previous cleanup synchronously, so the -// stale release lands before this hold rather than after it. +// A view no longer decides whether an agent runs: a send starts one, and the idle sweep stops it. +// So both are no-ops. `hold` still builds the host, because shipped mobile builds build it through +// `hold` before they subscribe. Delete both, and their allowlist entries, once +// MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes every client that still calls them. -import { defineMethod, type RpcContext } from '../core' +import { defineMethod } from '../core' import { - ensureStructuredHostInstalled, - requireStructuredCleanupHost, - requireStructuredHost + requireInstalledStructuredHost, + requireStructuredCleanupHost } from './structured-agent-session-gate' import { HoldParams } from './structured-agent-session-schemas' -const HOLD_CLEANUP_PREFIX = 'agentSession.hold' - -function holderKeyFor(ctx: RpcContext, holderId: string): string { - const client = ctx.clientId?.trim() || (ctx.clientKind ?? 'runtime') - return `${ctx.connectionId ?? 'local'}:${client}:${holderId}` -} - -function holdCleanupIdFor(sessionId: string, holderKey: string): string { - return `${HOLD_CLEANUP_PREFIX}:${holderKey}:${sessionId}` -} - export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', params: HoldParams, - handler: async (params, ctx) => { - await ensureStructuredHostInstalled(ctx) - const host = requireStructuredHost(ctx) - const holderKey = holderKeyFor(ctx, params.holderId) - const registration = ctx.runtime.registerOwnedSubscriptionCleanup( - holdCleanupIdFor(params.sessionId, holderKey), - () => host.release(params.sessionId, holderKey), - ctx.connectionId - ) - try { - await host.hold(params.sessionId, holderKey) - } catch (error) { - registration.releaseIfCurrent() - throw error - } + handler: async (_params, ctx) => { + await requireInstalledStructuredHost(ctx) return { held: true as const } } }), defineMethod({ name: 'agentSession.release', params: HoldParams, - handler: async (params, ctx) => { - const host = requireStructuredCleanupHost(ctx) - const holderKey = holderKeyFor(ctx, params.holderId) - host.release(params.sessionId, holderKey) - // Retires the backstop too; its release is a no-op against a holder already gone. - ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey)) + handler: async (_params, ctx) => { + requireStructuredCleanupHost(ctx) return { released: true as const } } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index b74b9cacfa66..19e936895771 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -1,22 +1,26 @@ // The options read surface: what a session reports about itself, and the // host's stored model catalog behind the picker. // -// `agentSession.options` serializes on the session because it asks the live -// provider what is selected. `agentSession.modelCatalog` deliberately does +// `agentSession.options` answers at rest from the record and the catalog, and +// asks the live provider only when one runs. `agentSession.modelCatalog` deliberately does // neither — answering from the host store is what lets a picker render while // an attach is still running. It is additive: an older host answers // `method_not_found` (or `forbidden` through the mobile allowlist gate), and // the client keeps its static seed. import { defineMethod } from '../core' -import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { + requireInstalledStructuredHost, + requireStructuredHost as requireHost +} from './structured-agent-session-gate' import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.options', params: OptionsParams, - handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId) + handler: async (params, ctx) => + (await requireInstalledStructuredHost(ctx)).readOptions(params.sessionId) }), defineMethod({ name: 'agentSession.modelCatalog', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index b3a70334f409..a48f897a005e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -64,20 +64,15 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ } }), defineMethod({ - // Reattach only, no send. No Orca surface calls it now — the desktop prompt's single action is - // resume-and-continue — but it is a PUBLISHED wire method, so dropping it is a wire removal an - // older or non-desktop client would meet as an unknown method. + // Reattach only, no send. Reattaching is nothing now — an agent starts only for work — so this + // answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire + // method, so dropping it is a wire removal an older client would meet as an unknown method. name: 'agentSession.restartResume', params: RestartResumeParams, - handler: async (params, ctx) => { + handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) - const host = requireStructuredHost(ctx) - return { - results: await host.restartResume.resume( - params.sessionIds, - structuredCallerFor(ctx).callerKey - ) - } + requireStructuredHost(ctx) + return { results: [] } } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a9cf5cd79576..9a0f3902496b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -203,8 +203,7 @@ export function hostStub(): StructuredAgentSessionHost { subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) => statusFeed().subscribe(subscriber) ), - unsubscribe: vi.fn(), - release: vi.fn() + unsubscribe: vi.fn() }) return hostCalls as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts index 0393cf216c84..473f63313729 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionHistoryPage, @@ -87,7 +87,6 @@ describe('turn item capability at the RPC boundary', () => { it.each(['snapshot', 'batch', 'reset'] as const)( 'downgrades the %s stream only for a legacy reader', async (type) => { - hostCalls.hold = vi.fn(async () => undefined) hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { const base = { sessionId: SESSION, fence: 1 } if (type === 'batch') { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 07e006ac6c7c..5a880d28a8d6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -337,7 +337,9 @@ describe('capability gating', () => { ok: false, error: { message: expect.stringContaining('structured_agent_session_unsupported') } }) - expect(hostCalls[hostCall]).not.toHaveBeenCalled() + if (hostCall !== null) { + expect(hostCalls[hostCall]).not.toHaveBeenCalled() + } } ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 66d44d4b7820..3c44134d76c6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -18,6 +18,7 @@ import { import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, + requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, requireStructuredCleanupHost, requireStructuredHost as requireHost, @@ -234,19 +235,20 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.handoffStatus', params: HandoffStatusParams, - handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId) + handler: async (params, ctx) => + (await requireInstalledHost(ctx)).handoffStatus(params.sessionId) }), defineMethod({ name: 'agentSession.commands', params: OptionsParams, - handler: async (params, ctx) => requireHost(ctx).readCommands(params.sessionId) + handler: async (params, ctx) => (await requireInstalledHost(ctx)).readCommands(params.sessionId) }), defineMethod({ name: 'agentSession.history', params: HistoryParams, handler: async (params, ctx) => projectTurnItemHistory( - projectBackgroundTaskHistory(requireHost(ctx).history(params), ctx), + projectBackgroundTaskHistory(await (await requireInstalledHost(ctx)).history(params), ctx), ctx ) }), @@ -254,25 +256,17 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.subscribe', params: SubscribeParams, handler: async (params, ctx, emit) => { - const host = requireHost(ctx) + const host = await requireInstalledHost(ctx) const subscriptionId = subscriptionIdFor(ctx, params.sessionId) - // A live stream is a surface too: it keeps a session from being evicted while it is read and - // releases that retention when the transport dies without a word. - // - // Retain-only: reading history must never be what starts a provider process. Current clients - // explicitly hold every open surface before subscribing. - const streamHolder = `subscription:${subscriptionId}` + // A stream reads; it never keeps an agent alive or starts one. let dispose = (): void => {} - const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => { - dispose() - host.release(params.sessionId, streamHolder) - }) + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => dispose()) if (stream.isClosed()) { return } - // The host emits the opening snapshot (or the missed batch) synchronously - // inside open(), so nothing between here and there can interleave. - dispose = host.subscribe({ + // Resolves once the conversation is open and the opening snapshot (or the missed batch) is + // emitted; a close that raced the open disposes what it bound. + dispose = await host.subscribe({ id: subscriptionId, sessionId: params.sessionId, emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), @@ -280,14 +274,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }) if (stream.isClosed()) { dispose() - } else { - // Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a - // readable session, which is exactly what the client sees anyway. - void host - .hold(params.sessionId, streamHolder, { resume: false }) - .catch((error: unknown) => - console.warn('[agent-session] stream hold failed', params.sessionId, error) - ) } } }), diff --git a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts index 1a97223bc395..b376f2c80d9e 100644 --- a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts @@ -79,7 +79,7 @@ function read(cursor?: string, limit?: number) { }) } -function textsOf(result: ReturnType): string[] { +function textsOf(result: Awaited>): string[] { return result.transcript.messages.map((entry) => entry.blocks.map((block) => ('text' in block ? block.text : '')).join('') ) @@ -90,57 +90,57 @@ describe('the structured worker-read cursor over a mutating journal', () => { hostRef.current = null }) - it('refuses to resume when an already-delivered item was revised in place', () => { + it('refuses to resume when an already-delivered item was revised in place', async () => { // The `"hel"` / `"hello"` defect. The caller is handed a coalesced snapshot, resumes past it, // and the item is later revised at its original sequence — under the old anchor the resume was // accepted and that revision was never delivered to anyone. installJournal([message('i1', 'hel'), message('i2', 'second')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) expect(textsOf(first)).toEqual(['hel']) installJournal([message('i1', 'hello world', 2), message('i2', 'second')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) - it('refuses to resume when a resolved prompt inserts ahead of the caller position', () => { + it('refuses to resume when a resolved prompt inserts ahead of the caller position', async () => { // Duplication. A pending approval projects to null, so resolving it inserts a message in the // MIDDLE; the oldest item never moved, so the old anchor accepted a now-stale index and the // caller re-read content it already had. installJournal([message('i1', 'first'), approval('i2', false), message('i3', 'second')]) - const first = read(undefined, 2) + const first = await read(undefined, 2) expect(textsOf(first)).toEqual(['first', 'second']) installJournal([message('i1', 'first'), approval('i2', true, 2), message('i3', 'second')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) - it('still resumes across a page boundary when only unread tail items change', () => { + it('still resumes across a page boundary when only unread tail items change', async () => { // The reason this is prefix-scoped and not whole-page: during an active turn the coalescer // revises the streaming item every 60ms. Fingerprinting the whole page would invalidate the // cursor continuously — a useless verb — while the worker is working. installJournal([message('i1', 'first'), message('i2', 'streaming')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) expect(textsOf(first)).toEqual(['first']) installJournal([message('i1', 'first'), message('i2', 'streaming more', 7)]) - const second = read(first.cursor) + const second = await read(first.cursor) expect(textsOf(second)).toEqual(['streaming more']) }) - it('delivers every message exactly once when nothing below the cursor changes', () => { + it('delivers every message exactly once when nothing below the cursor changes', async () => { // The property the two refusals above protect: no omission, no duplication. installJournal([message('i1', 'a'), message('i2', 'b'), message('i3', 'c')]) - const first = read(undefined, 2) - const second = read(first.cursor, 2) + const first = await read(undefined, 2) + const second = await read(first.cursor, 2) expect([...textsOf(first), ...textsOf(second)]).toEqual(['a', 'b', 'c']) }) - it('still refuses when the window slides off the front', () => { + it('still refuses when the window slides off the front', async () => { // The case the old anchor DID catch, and which the prefix scoping must not lose: a slide // shifts every index. installJournal([message('i1', 'a'), message('i2', 'b')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) installJournal([message('i2', 'b'), message('i3', 'c')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) }) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index ce67fb94aff7..a543a8da45d0 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -230,6 +230,8 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.history', 'agentSession.subscribe', 'agentSession.unsubscribe', + // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the + // mobile builds that still call them. 'agentSession.hold', 'agentSession.release', 'nativeChat.readSession', diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index f65d1204db9b..f420b69a81a5 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -15,7 +15,10 @@ import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wi import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from './orca-runtime' import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' -import { observeStructuredWorker } from './structured-worker-authority' +import { + observeStructuredWorker, + structuredSessionCloseSettled +} from './structured-worker-authority' export type StructuredAgentSessionCloseOutcome = { stopped: boolean @@ -86,8 +89,8 @@ export async function closeStructuredAgentSessionChild( } } options.afterClose?.() - const observation = observeStructuredWorker({ sessionId }) - if (observation.status !== 'exited') { + if (!structuredSessionCloseSettled(sessionId)) { + const observation = observeStructuredWorker({ sessionId }) await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails) return { stopped: false, @@ -137,7 +140,7 @@ async function restorePersistedTabVisibility( sessionId: string, restoreTab: boolean ): Promise { - if (!restoreTab || observeStructuredWorker({ sessionId }).status === 'exited') { + if (!restoreTab || structuredSessionCloseSettled(sessionId)) { return } try { diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index d81170ee075b..1b7136a996df 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -289,10 +289,10 @@ function textOf(item: AgentJournalRenderItem): string { } /** The durable submission row, which settlement rewrites after the send returns. */ -function submissionOf(clientMessageId: string): AgentJournalSubmission | undefined { - return getStructuredAgentSessionHost() - ?.journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submissionOf(clientMessageId: string): Promise { + return (await getStructuredAgentSessionHost()?.journalSnapshot(SESSION))?.submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } async function historyPage( @@ -495,8 +495,8 @@ describe('a structured codex session over agentSession.*', () => { // The echo is the first item of this turn, so the settled key is ordinal 0 — // minted by the same `identityFor` a history replay computes with, rather // than guessed from the turn/start response. - await vi.waitFor(() => - expect(submissionOf(sent.clientMessageId)).toMatchObject({ + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) @@ -584,8 +584,8 @@ describe('a structured codex session over agentSession.*', () => { expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) // Settled from the echo's own journal identity, so it is by construction the // key a replay recomputes for this row. - await vi.waitFor(() => - expect(submissionOf(sent.clientMessageId)).toMatchObject({ + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) 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 0b2c8bd4f4b6..3da0c6f203d2 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -32,7 +32,7 @@ describe('structured session runtime provider-exit wiring', () => { } }) - it('reacquires through the production callback and accepts a distinct next message', async () => { + it('does not respawn after a provider exit, and the next message starts a new child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-runtime-provider-exit-')) operations = 0 const connections: { @@ -98,16 +98,17 @@ describe('structured session runtime provider-exit wiring', () => { JSON.stringify({ refusal: attached.refusal, connections: connections.length }) ) } - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const exited = connections[0] exited?.handlers.onExit?.(new Error('scripted provider exit')) - - await vi.waitFor(() => expect(connections).toHaveLength(2)) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) + // Nothing restarts the child on its own; the exit is shown and the chat waits for a send. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(connections).toHaveLength(1) const recoveredFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence - expect(recoveredFence).toBeGreaterThan(exitedFence) if (recoveredFence === undefined) { - throw new Error('recovered lease omitted its fence') + throw new Error('released lease omitted its fence') } const body = hostTestMessage('continue with a distinct message') const envelope = { @@ -128,7 +129,8 @@ describe('structured session runtime provider-exit wiring', () => { await expect( host.send({ callerKey: 'runtime-test' }, { envelope, body }) ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // The send answers at acceptance; the delivery loop hands it to the reacquired child after. + // The send answers at acceptance; the delivery loop starts a new child and hands it over. + await vi.waitFor(() => expect(connections).toHaveLength(2)) await vi.waitFor(() => expect(turn).toBe(1)) }) @@ -200,7 +202,6 @@ describe('structured session runtime provider-exit wiring', () => { JSON.stringify({ refusal: attached.refusal, connections: connections.length }) ) } - await host.hold(SESSION, 'desktop-chat:requested-close') await stopStructuredAgentSessionRuntime() await new Promise((resolve) => setImmediate(resolve)) @@ -224,7 +225,7 @@ describe('structured session runtime provider-exit wiring', () => { readProcessStartTime: async () => 1_700_000_000_000 }) await restarted.restoreReadableSessions() - const history = restarted.history({ sessionId: SESSION, direction: 'tail' }) + 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({ provider: 'codex', @@ -232,7 +233,7 @@ describe('structured session runtime provider-exit wiring', () => { }) }) - it('waits for an in-flight recovery before tearing down the runtime', async () => { + it('waits for a start a send began before tearing down the runtime', async () => { root = await mkdtemp(join(tmpdir(), 'orca-runtime-recovery-shutdown-')) let releaseRecovery!: () => void const recoveryReleased = new Promise((resolve) => { @@ -301,8 +302,28 @@ describe('structured session runtime provider-exit wiring', () => { attachParams.envelope.clientOperationId = operationId() const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) expect(attached.ok).toBe(true) - await host.hold(SESSION, 'desktop-chat:shutdown-race') - connections[0]?.handlers.onExit?.(new Error('recovery is still opening')) + connections[0]?.handlers.onExit?.(new Error('the first child exited')) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) + const body = hostTestMessage('start again') + await host.send( + { callerKey: 'runtime-test' }, + { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + // The send's start is still opening its connection when the quit begins. await vi.waitFor(() => expect(opens).toBe(2)) let stopped = false @@ -388,7 +409,6 @@ describe('structured session runtime provider-exit wiring', () => { expect(await host.attach({ callerKey: 'runtime-test' }, attachParams)).toMatchObject({ ok: true }) - await host.hold(SESSION, 'desktop-chat:backstop') await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow() await new Promise((resolve) => setImmediate(resolve)) @@ -397,7 +417,7 @@ describe('structured session runtime provider-exit wiring', () => { expect(closeAttempts).toBeGreaterThanOrEqual(2) // The callback it delivered neither reacquired nor wrote a technical row. expect(connections).toHaveLength(1) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) // The aborted eviction left the session reachable, so the next teardown is a real retry. diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 602507ef4d7e..91bf91738b5b 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -1,5 +1,5 @@ -// A Claude child proving its start must not wait on another session's exit recovery, and must -// not make that recovery, or the session's own serialized operations, wait on the CLI: the host +// A Claude child proving its start must not wait on another session's start, and must not make +// that start, or the session's own serialized operations, wait on the CLI: the host // records what the child proved from what the adapter already holds. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -18,7 +18,7 @@ afterEach(async () => { }) describe('a Claude child proving its start', () => { - it("never holds another session's exit recovery, or its own close, on a CLI read", async () => { + it("never holds another session's start, or its own close, on a CLI read", async () => { claude.behave(STALLED, { stallsControlReads: true }) const host = await claude.install() @@ -56,23 +56,15 @@ describe('a Claude child proving its start', () => { await vi.waitFor(() => expect(closed).toBe(true)) }) - it("flips to ready while another session's exit recovery is still acquiring", async () => { + it("flips to ready while another session's start is still acquiring", async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ - ok: true - }) - await host.hold(HEALTHY, 'surface-1') - await waitForStructuredAgentSessionRecovery() - - // Its exit recovery reacquires, and that spawn never returns. The exit hands the lease back - // and the restart queued behind it reserves it again at once, so `released` is not a state a - // poll can count on seeing; `reserved` with the spawn hanging is what "still acquiring" is. + // This start never returns from its spawn, so its lease stays reserved with no child. claude.behave(HEALTHY, { spawnHangs: true }) - claude.child(HEALTHY).exit(new Error('claude stream-json exited (code 1): crashed')) + void host.attach(CALLER, claude.attachParams(HEALTHY, null)).catch(() => undefined) await vi.waitFor(() => expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') ) - expect(claude.children(HEALTHY)).toHaveLength(1) + const childrenWhileHung = claude.children(HEALTHY).length await expect(host.attach(CALLER, claude.attachParams(STALLED, null))).resolves.toMatchObject({ ok: true @@ -83,9 +75,9 @@ describe('a Claude child proving its start', () => { effort: 'high' }) ) - // The other recovery is still where it was: reserved, with no child yet. + // The other start is still where it was: reserved, with no new child. expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') - expect(claude.children(HEALTHY)).toHaveLength(1) + expect(claude.children(HEALTHY)).toHaveLength(childrenWhileHung) }) it('is drained by the runtime before teardown proceeds', async () => { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 91595eb124f8..cac10badafc8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -104,6 +104,8 @@ export type StructuredAgentSessionRuntimeDeps = { onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] /** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */ statusSink?: StructuredAgentSessionHostDeps['statusSink'] + /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ + hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] reapOrphanChildren?: typeof stopOrphanAgentSessionChildren /** The account home a structured launch would pin right now, for catalog * reads with no session record. Absent disables the catalog surface. */ @@ -310,6 +312,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { + observeStructuredWorker, + resolveStructuredWorkerAuthority, + structuredSessionCloseSettled, + structuredWorkerOwned +} = await import('./structured-worker-authority') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerHasOpenDispatch, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('./structured-worker-identity') +const { listAddressableStructuredWorkers } = + await import('./orchestration/structured-worker-group-addressing') +const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') +const { resolveGroupAddress } = await import('./orchestration/groups') + +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const LOCAL = JSON.stringify({ kind: 'local', hostId: 'local' }) + +function record(lease: Partial): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial record; the ownership and liveness reads touch only lease, location and provider. + return { + sessionId: SESSION, + provider: 'codex', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'wt_1', + workspaceKind: 'git-worktree' + }, + lease: { claimStatus: 'live', deathEvidence: null, runtimeFence: 3, ...lease } + } as AgentSessionRecord +} + +function installHost(current: AgentSessionRecord | null, tabs: string[]) { + const host = { + deps: { store: { getRecord: () => current } }, + hasSession: () => false, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: tabs }), + setSessionTabVisibility: vi.fn(async (_id: string, visible: boolean) => { + tabs.splice(0, tabs.length, ...(visible ? [SESSION] : [])) + }), + close: vi.fn(async () => undefined) + } + hostRef.current = host + return host +} + +let paneKey = '' + +function registerWorker(): string { + const handle = mintStructuredWorkerHandle() + paneKey = mintStructuredWorkerPaneKey(SESSION) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION, + agent: 'codex', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return handle +} + +beforeEach(() => { + structuredWorkerIdentities.clear() + hostRef.current = null +}) + +describe('an open dispatch keeps its worker running (P2-19 i)', () => { + let db: OrchestrationDb + beforeEach(() => { + db = new OrchestrationDb(':memory:') + }) + afterEach(() => db.close()) + + function dispatchWorker(hostScope = LOCAL) { + const task = db.createTask({ runId: 'run_legacy_local', spec: 'structured work' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(SESSION), + processIncarnation: structuredWorkerProcessIncarnation(SESSION), + worktreeId: 'wt_1', + effects: [], + setupState: 'not_configured', + hostScope, + terminalOwnership: 'created' + }) + return dispatch + } + + it('answers open while the dispatch starts, runs or stops, and closed once it settles', () => { + const dispatch = dispatchWorker() + expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(true) + db.markWorkerDispatchReady(dispatch.id) + expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(true) + db.beginWorkerStop(dispatch.id, 'epoch_home') + expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(true) + db.settleWorkerStop(dispatch.id) + expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(false) + }) + + it('keeps a worker at rest after its dispatch settles a group recipient, until its tab goes (P2-19 ii)', () => { + const dispatch = dispatchWorker() + db.markWorkerDispatchReady(dispatch.id) + db.beginWorkerStop(dispatch.id, 'epoch_home') + db.settleWorkerStop(dispatch.id) + // Settlement forgets the in-memory entry; only the durable row and the record remain. + structuredWorkerIdentities.clear() + const tabs = [SESSION] + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped by the sweep', observedAt: 1 } + }), + tabs + ) + const handle = db.getWorkerTerminalResourceByOwner(dispatch.id)!.terminal_handle + const recipients = () => listAddressableStructuredWorkers(db) + + expect(recipients()).toEqual([{ handle, worktreeId: 'wt_1', agentIdentity: 'codex' }]) + expect(resolveGroupAddress('@codex', 'term_sender', recipients(), () => 'idle')).toEqual([ + handle + ]) + expect(resolveGroupAddress('@claude', 'term_sender', recipients(), () => 'idle')).toEqual([]) + // Direct mail resolves the same worker through the same ownership answer. + expect(resolveStructuredWorkerAuthority(handle, db)?.identity.handle).toBe(handle) + + tabs.length = 0 + expect(recipients()).toEqual([]) + expect(resolveStructuredWorkerAuthority(handle, db)).toBeNull() + }) + + it('reads only this host scope, and no database answers no', () => { + dispatchWorker(JSON.stringify({ kind: 'ssh', targetId: 'elsewhere' })) + expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(false) + expect(structuredWorkerHasOpenDispatch(null, record({}))).toBe(false) + }) +}) + +describe('ownership, not liveness (P2-19 ii-iv, P2-26)', () => { + it('owns a worker at rest while its tab is listed, and routes mail to it', () => { + const handle = registerWorker() + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped', observedAt: 1 } + }), + [SESSION] + ) + + expect(structuredWorkerOwned(SESSION)).toBe(true) + expect(observeStructuredWorker({ sessionId: SESSION }).status).toBe('exited') + expect(resolveStructuredWorkerAuthority(handle, null)?.identity.paneKey).toBe(paneKey) + }) + + it('retires a released worker whose tab is gone', () => { + const handle = registerWorker() + installHost(record({ claimStatus: 'released' }), []) + + expect(structuredWorkerOwned(SESSION)).toBe(false) + expect(resolveStructuredWorkerAuthority(handle, null)).toBeNull() + }) + + it('keeps authority for a live session that has no tab', () => { + const handle = registerWorker() + installHost(record({ claimStatus: 'live' }), []) + + expect(structuredWorkerOwned(SESSION)).toBe(true) + expect(resolveStructuredWorkerAuthority(handle, null)).not.toBeNull() + }) + + it('cannot answer without a host', () => { + expect(structuredWorkerOwned(SESSION)).toBeNull() + }) +}) + +describe('a close whose stop could not be proven (P2-30)', () => { + it('closes on the first attempt, keeps the tab retired and leaves the verdict alone', async () => { + const released = record({ claimStatus: 'released', deathEvidence: null }) + const tabs = [SESSION] + const host = installHost(released, tabs) + expect(observeStructuredWorker({ sessionId: SESSION }).status).toBe('unverifiable') + + const outcome = await closeStructuredAgentSessionChild(SESSION) + expect(outcome).toMatchObject({ stopped: true, closeAttempted: true }) + expect(host.close).toHaveBeenCalledOnce() + expect(tabs).toEqual([]) + expect(host.setSessionTabVisibility).not.toHaveBeenCalledWith(SESSION, true) + expect(released.lease.deathEvidence).toBeNull() + expect(structuredSessionCloseSettled(SESSION)).toBe(true) + }) + + it('still refuses a close that left the lease live', () => { + installHost(record({ claimStatus: 'live' }), [SESSION]) + expect(structuredSessionCloseSettled(SESSION)).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index c11119878f63..eec3cbb798fd 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -3,8 +3,9 @@ * * The registry holds the handle→session mapping for this process; the durable worker-terminal * resource row is what survives a restart, so a miss falls back to rehydrating from it. The - * durable agent-session record is the liveness half: a session whose claim is conflicted or - * released, or pinned to another execution host, is no longer this runtime's structured worker. + * durable agent-session record and the chat's tab are the ownership half: see `structuredWorkerOwned`. + * Whether its provider process runs is a separate fact, `observeStructuredWorker`, and routing + * never reads it — an agent at rest still receives mail, which starts it. */ import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -47,6 +48,35 @@ export function resolveStructuredWorkerIdentity( return row ? structuredWorkerIdentities.rehydrate(row) : null } +/** + * Whether this runtime still owns the worker's session: routing, addressing and authority ask + * this, never whether its process runs. Null when the host is not installed, because reading the + * record store would install it — not being able to look is not an answer. + */ +export function structuredWorkerOwned(sessionId: string): boolean | null { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + const record = readStructuredAgentSessionRecord(sessionId) + return structuredWorkerRecordIsCurrent( + record, + record?.lease.claimStatus === 'released' && structuredWorkerTabListed(host, sessionId) + ) +} + +/** Retirement is the tab index: every path that ends a chat for good hides its tab. */ +function structuredWorkerTabListed( + host: NonNullable>, + sessionId: string +): boolean { + try { + return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + } catch { + return false + } +} + /** Identity plus a record that still proves this runtime owns the session. */ export function resolveStructuredWorkerAuthority( handle: string, @@ -57,7 +87,7 @@ export function resolveStructuredWorkerAuthority( return null } const record = readStructuredAgentSessionRecord(identity.sessionId) - return record && structuredWorkerRecordIsCurrent(record) ? { identity, record } : null + return record && structuredWorkerOwned(identity.sessionId) ? { identity, record } : null } /** @@ -80,6 +110,20 @@ export type StructuredWorkerObservation = { reason?: string } +/** + * Whether a close left nothing running: `exited`, or `unverifiable` on a released lease — a release + * whose stop could not be proven, which sent no signal and is left as it is. Closing a chat is the + * user's action, and bookkeeping about a process already released must not refuse it. + */ +export function structuredSessionCloseSettled(sessionId: string): boolean { + const status = observeStructuredWorker({ sessionId }).status + return ( + status === 'exited' || + (status === 'unverifiable' && + readStructuredAgentSessionRecord(sessionId)?.lease.claimStatus === 'released') + ) +} + /** * The observation as the terminal state every read result reports. * diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 98989a85891c..d20cadf5c770 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -176,18 +176,24 @@ describe('structured worker identity', () => { it('keeps a recovered session current across a fence bump', () => { // The host bumps the fence on its own transparent crash recovery; fencing identity on it // would wedge the SAME worker as identity_unproven forever. - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }))).toBe(true) - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }))).toBe(true) + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }), false)).toBe(true) + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }), false)).toBe(true) expect(structuredWorkerProcessIncarnation(SESSION_ID)).toBe( structuredWorkerProcessIncarnation(SESSION_ID) ) }) - it('refuses a conflicted or released session', () => { + it('refuses a conflicted session, and a released one whose chat tab is gone', () => { // A terminal owner an older build recorded loads conflicted; it is not this worker. - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }))).toBe(false) - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }))).toBe(false) - expect(structuredWorkerRecordIsCurrent(null)).toBe(false) + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }), true)).toBe(false) + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), false)).toBe(false) + expect(structuredWorkerRecordIsCurrent(null, true)).toBe(false) + }) + + it('keeps a released session with a listed tab: that worker is at rest, not gone', () => { + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), true)).toBe(true) + // A live lease is owned whether or not a tab shows it. + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'live' }), false)).toBe(true) }) }) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 5be84c336ae8..7d65b1037f7c 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -29,6 +29,7 @@ import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' +import type { OrchestrationDb } from './orchestration/db' // Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the // renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. @@ -131,19 +132,79 @@ export function structuredWorkerHostScope( : null } -/** Whether the durable record still describes THIS worker under this host. */ +/** + * Whether the durable record still describes THIS worker under this host — ownership, not whether + * its process runs. A released lease is a worker at rest while its chat tab is listed; released + * with the tab gone is retired. `tabListed` is the persisted tab index's answer. + */ export function structuredWorkerRecordIsCurrent( - record: AgentSessionRecord | null | undefined + record: AgentSessionRecord | null | undefined, + tabListed: boolean ): boolean { return Boolean( record && // Why: a conflicted claim may name a terminal an older build recorded as owner, not this worker. record.lease.claimStatus !== 'conflicted' && - record.lease.claimStatus !== 'released' && + (record.lease.claimStatus !== 'released' || tabListed) && structuredWorkerHostScope(record.location) ) } +/** Whether a dispatch that has not settled still owns this session's worker, keyed the way the + * worker's terminal resource row is: its process incarnation and host scope. */ +export function structuredWorkerHasOpenDispatch( + db: OrchestrationDb | null, + record: AgentSessionRecord +): boolean { + const hostScope = structuredWorkerHostScope(record.location) + return Boolean( + db && + hostScope && + db.hasOpenWorkerDispatchForProcess({ + processIncarnation: structuredWorkerProcessIncarnation(record.sessionId), + hostScope: JSON.stringify(hostScope) + }) + ) +} + +type StructuredWorkerResourceRow = { + terminal_handle: string + pane_key: string | null + process_incarnation: string | null + worktree_id: string | null + host_scope: string | null +} + +/** A worker's identity as its durable worker-terminal resource row records it, or null for a row + * that is not a structured worker's or whose pane key does not belong to its own session. */ +export function structuredWorkerIdentityFromRow( + row: StructuredWorkerResourceRow +): StructuredWorkerIdentity | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(row.process_incarnation) + const hostScope = parseWorkerTerminalHostScope(row.host_scope) + const paneKey = row.pane_key + if ( + !sessionId || + !hostScope || + !row.worktree_id || + !paneKey || + !isStructuredWorkerHandle(row.terminal_handle) || + !persistedStructuredWorkerPaneKeyIsValid(paneKey, sessionId) + ) { + return null + } + return { + handle: row.terminal_handle, + sessionId, + // The row does not carry the provider; callers that need it read the durable record. + agent: null, + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(sessionId), + worktreeId: row.worktree_id, + hostScope + } +} + export class StructuredWorkerIdentityRegistry { private readonly byHandle = new Map() private readonly bySessionId = new Map() @@ -183,35 +244,9 @@ export class StructuredWorkerIdentityRegistry { * only place a structured worker's pane key and host scope outlive this process. A row whose * pane key does not belong to its own recorded session is refused rather than trusted. */ - rehydrate(row: { - terminal_handle: string - pane_key: string | null - process_incarnation: string | null - worktree_id: string | null - host_scope: string | null - }): StructuredWorkerIdentity | null { - const sessionId = sessionIdFromStructuredWorkerIncarnation(row.process_incarnation) - const hostScope = parseWorkerTerminalHostScope(row.host_scope) - if ( - !sessionId || - !hostScope || - !row.worktree_id || - !isStructuredWorkerHandle(row.terminal_handle) || - // The durable row bootstraps the registry after restart, so validate it before registration. - !persistedStructuredWorkerPaneKeyIsValid(row.pane_key, sessionId) - ) { - return null - } - return this.register({ - handle: row.terminal_handle, - sessionId, - // The row does not carry the provider; callers that need it read the durable record. - agent: null, - paneKey: row.pane_key, - processIncarnation: structuredWorkerProcessIncarnation(sessionId), - worktreeId: row.worktree_id, - hostScope - }) + rehydrate(row: StructuredWorkerResourceRow): StructuredWorkerIdentity | null { + const identity = structuredWorkerIdentityFromRow(row) + return identity ? this.register(identity) : null } clear(): void { diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts index c426132b8485..0413c7b6fa64 100644 --- a/src/main/runtime/structured-worker-mail-routing.test.ts +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -20,9 +20,16 @@ const prototype = OrcaRuntimeWithAdoptTerminalOrphansFromInventory.prototype const getLivePaneKey = prototype.getLiveTerminalPaneKey const resolveActiveTerminal = prototype.resolveActiveTerminal -function installRecord(lease: { runtimeKind: string; claimStatus: string } | null): void { +function installRecord( + lease: { runtimeKind: string; claimStatus: string } | null, + tabListed = false +): void { hostRef.current = lease ? { + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: tabListed ? [SESSION_ID] : [] + }), deps: { store: { getRecord: () => ({ @@ -73,10 +80,19 @@ describe('bare-handle direct mail to a structured session', () => { ) }) - it('withholds the pane key when the session is not proven live', () => { - // The PTY branch is connected-gated so mail is never routed to a corpse; so is this one. + it('routes to a worker at rest, whose agent the mail starts', () => { + // Released by the idle sweep with its chat tab still listed: owned, so mail reaches it. const handle = registerWorker() - installRecord({ runtimeKind: 'native', claimStatus: 'reserved' }) + installRecord({ runtimeKind: 'native', claimStatus: 'released' }, true) + expect(getLivePaneKey.call(paneKeyStub, handle)).toBe( + structuredWorkerIdentities.get(handle)!.paneKey + ) + }) + + it('withholds the pane key from a retired worker: released, with its tab gone', () => { + // Mail is never routed to a worker this runtime no longer owns. + const handle = registerWorker() + installRecord({ runtimeKind: 'native', claimStatus: 'released' }, false) expect(getLivePaneKey.call(paneKeyStub, handle)).toBeNull() }) diff --git a/src/main/runtime/structured-worker-terminal-read.test.ts b/src/main/runtime/structured-worker-terminal-read.test.ts index 802b23733bca..e49f24ba1a15 100644 --- a/src/main/runtime/structured-worker-terminal-read.test.ts +++ b/src/main/runtime/structured-worker-terminal-read.test.ts @@ -75,22 +75,22 @@ describe('reading a structured worker through the terminal-read path', () => { hostRef.current = null }) - it('serves the journal as terminal lines, with no dispatch and no capability', () => { + it('serves the journal as terminal lines, with no dispatch and no capability', async () => { // The defect this pins: a peer has no dispatch id and no coordinator standing, so `worker-read` // is closed to it, and `terminal read` threw `terminal_handle_stale` for a perfectly live // worker. A peer could not see a structured agent's recent output at all. const handle = registerWorker() installHost({ items: [message('i1', 'first line\nsecond line'), message('i2', 'done')] }) - const read = readStructuredWorkerTerminal({ handle, db: null }) + const read = await readStructuredWorkerTerminal({ handle, db: null }) expect(read?.tail).toEqual(['[assistant] first line', 'second line', '[assistant] done']) expect(read?.status).toBe('running') expect(read?.truncated).toBe(false) }) - it('honours limit, and claims no cursor space it cannot honour', () => { + it('honours limit, and claims no cursor space it cannot honour', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'a'), message('i2', 'b'), message('i3', 'c')] }) - const read = readStructuredWorkerTerminal({ handle, db: null, limit: 2 }) + const read = await readStructuredWorkerTerminal({ handle, db: null, limit: 2 }) expect(read?.tail).toEqual(['[assistant] b', '[assistant] c']) // No index is advertised: the next read re-projects a sliding window, so 0/length would name // positions that address different lines by then. @@ -99,7 +99,7 @@ describe('reading a structured worker through the terminal-read path', () => { expect(read?.latestCursor).toBeUndefined() }) - it('refuses a cursor read rather than silently misdelivering lines', () => { + it('refuses a cursor read rather than silently misdelivering lines', async () => { // The PTY cursor indexes an append-only completed-line buffer with a monotone count. This // window is a bounded tail re-projected every read, so a saved index addresses different lines // as the journal grows — and `truncated` could never fire to say so, because it tests @@ -107,14 +107,10 @@ describe('reading a structured worker through the terminal-read path', () => { // duplicated lines with `truncated:false`. const handle = registerWorker() installHost({ items: [message('i1', 'a')] }) - const refusal = (() => { - try { - readStructuredWorkerTerminal({ handle, db: null, cursor: 0 }) - return '' - } catch (error) { - return (error as Error).message - } - })() + const refusal = await readStructuredWorkerTerminal({ handle, db: null, cursor: 0 }).then( + () => '', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) expect(refusal).toMatch(/not line-addressable/) // Tells the caller what DOES work here. Polling a bounded newest-last tail and diffing fails // safe — a harmless re-read — where a broken cursor fails unsafe, as a silent hole. @@ -125,35 +121,35 @@ describe('reading a structured worker through the terminal-read path', () => { expect(refusal).not.toContain('worker-read') }) - it('reports dropped history as truncated rather than pretending the page is whole', () => { + it('reports dropped history as truncated rather than pretending the page is whole', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'tail only')], hasOlder: true }) - expect(readStructuredWorkerTerminal({ handle, db: null })?.truncated).toBe(true) + expect((await readStructuredWorkerTerminal({ handle, db: null }))?.truncated).toBe(true) }) - it('redacts dispatch capability tokens the same way the archive path does', () => { + it('redacts dispatch capability tokens the same way the archive path does', async () => { const handle = registerWorker() const token = `dcap_${'a'.repeat(32)}` installHost({ items: [message('i1', `token is ${token} here`)] }) - const tail = readStructuredWorkerTerminal({ handle, db: null })?.tail.join('\n') ?? '' + const tail = (await readStructuredWorkerTerminal({ handle, db: null }))?.tail.join('\n') ?? '' expect(tail).not.toContain(token) expect(tail).toContain('[dispatch capability redacted]') }) - it('refuses when the session is not attached rather than answering an empty tail', () => { + it('refuses when the session is not attached rather than answering an empty tail', async () => { // An empty tail is the claim "this worker has produced no output", which is a different and // false statement — and the one a caller cannot tell apart from a real silence. const handle = registerWorker() installHost({ items: 'unreadable' }) - expect(() => readStructuredWorkerTerminal({ handle, db: null })).toThrow( + await expect(readStructuredWorkerTerminal({ handle, db: null })).rejects.toThrow( 'agent_session_ownership_unknown' ) }) - it('reports a session it cannot verify as unknown, never as running', () => { + it('reports a session it cannot verify as unknown, never as running', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'said something')], hasSession: false }) - expect(readStructuredWorkerTerminal({ handle, db: null })?.status).toBe('unknown') + expect((await readStructuredWorkerTerminal({ handle, db: null }))?.status).toBe('unknown') }) it('is what `terminal read` answers with, ahead of the PTY lookup', async () => { @@ -182,15 +178,15 @@ describe('reading a structured worker through the terminal-read path', () => { }) }) - it('leaves every handle that is not a live structured worker to the PTY path', () => { + it('leaves every handle that is not a live structured worker to the PTY path', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'x')] }) - expect(readStructuredWorkerTerminal({ handle: 'term_abc', db: null })).toBeNull() + expect(await readStructuredWorkerTerminal({ handle: 'term_abc', db: null })).toBeNull() // A terminal owner an older build recorded loads conflicted: not this runtime's worker. installHost({ items: [message('i1', 'x')], lease: { runtimeKind: 'native', claimStatus: 'conflicted' } }) - expect(readStructuredWorkerTerminal({ handle, db: null })).toBeNull() + expect(await readStructuredWorkerTerminal({ handle, db: null })).toBeNull() }) }) diff --git a/src/main/runtime/structured-worker-terminal-read.ts b/src/main/runtime/structured-worker-terminal-read.ts index 9b4a118b8ae4..3d31c4c863c7 100644 --- a/src/main/runtime/structured-worker-terminal-read.ts +++ b/src/main/runtime/structured-worker-terminal-read.ts @@ -48,12 +48,12 @@ import { readTerminalTail } from './terminal-tail-read' * IS a structured worker never falls through: an unreadable journal refuses rather than answering * an empty tail, which a caller cannot tell from a worker that has said nothing. */ -export function readStructuredWorkerTerminal(args: { +export async function readStructuredWorkerTerminal(args: { handle: string db: OrchestrationDb | null cursor?: number limit?: number -}): RuntimeTerminalRead | null { +}): Promise { const identity = resolveStructuredWorkerAuthority(args.handle, args.db)?.identity if (!identity) { return null @@ -76,7 +76,7 @@ export function readStructuredWorkerTerminal(args: { 'A structured session has no durable line anchor to page from — nothing else does either.' ) } - const page = readStructuredJournalPage(identity.sessionId) + const page = await readStructuredJournalPage(identity.sessionId) if (!page) { // Honest refusal, and the same one the send lane reports: an empty tail would read as "this // worker has produced no output", which is a different and false claim. diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx new file mode 100644 index 000000000000..3fa684b0a519 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { NativeChatEmptyState } from './NativeChatEmptyState' + +afterEach(cleanup) + +it('keeps pointing the terminal-backed chat back to its terminal when a read fails', () => { + render() + expect( + screen.getByText( + 'The transcript could not be read. Toggle back to the terminal to keep working.' + ) + ).toBeInTheDocument() +}) + +it('tells the structured chat its read keeps retrying', () => { + render() + expect( + screen.getByText('The transcript could not be read. Orca keeps trying to load it.') + ).toBeInTheDocument() +}) + +it('shows the host message over either default', () => { + render() + expect(screen.getByText('disk full')).toBeInTheDocument() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index f7c20ef51f45..eb0a9e995d96 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -7,13 +7,17 @@ import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ kind, message, - agent + agent, + retrying = false }: { kind: 'loading' | 'empty' | 'error' | 'not-agent' message?: string agent?: NativeChatSession['agent'] + /** The read retries on its own (structured chat), so the error says so instead of pointing + * back to the terminal. */ + retrying?: boolean }): React.JSX.Element { - const copy = emptyStateCopy(kind, message, agent) + const copy = emptyStateCopy(kind, message, agent, retrying) return (
) : viewState.kind === 'error' ? ( - + ) : viewState.kind === 'empty' ? ( ) : ( diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts index ea97ec99a191..dd0301112399 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts @@ -34,11 +34,18 @@ describe('selectNativeChatViewState', () => { }) }) - it('maps error with its message', () => { - const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' })) + it('maps error with its message when nothing has loaded (P2-21)', () => { + const state = selectNativeChatViewState( + session({ messages: [], status: 'error', error: 'boom' }) + ) expect(state).toEqual({ kind: 'error', message: 'boom' }) }) + it('keeps a loaded transcript on screen when a later read fails (P2-21)', () => { + const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' })) + expect(state).toEqual({ kind: 'ready', isWorking: false }) + }) + it('maps empty when there are no messages', () => { expect(selectNativeChatViewState(session({ messages: [], status: 'ready' })).kind).toBe('empty') }) diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index 775635df1229..c3a0e348d631 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -16,17 +16,18 @@ export type NativeChatViewState = | { kind: 'ready'; isWorking: true } /** - * Decide which surface to render. Error is terminal, but any renderable message - * wins over loading/empty so optimistic first sends never get replaced by a - * full-pane placeholder while transcript discovery catches up. + * Decide which surface to render. Any renderable message wins, error included: a read that fails + * after the transcript loaded must not replace it with a full-pane placeholder, and the error still + * reaches the composer's error line. Messages also win over loading/empty so optimistic first sends + * never get replaced while transcript discovery catches up. */ export function selectNativeChatViewState(session: NativeChatSession): NativeChatViewState { - if (session.status === 'error') { - return { kind: 'error', message: session.error ?? 'Conversation could not be loaded.' } - } if (session.messages.length > 0) { return { kind: 'ready', isWorking: session.status === 'working' } } + if (session.status === 'error') { + return { kind: 'error', message: session.error ?? 'Conversation could not be loaded.' } + } if (session.status === 'loading') { return { kind: 'loading' } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 5e5be1b6b3b4..0305b93cb6d4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -269,7 +269,7 @@ describe('structured agent-session read transport unattached refusals', () => { // ...and the failure is owed once it has passed. await vi.advanceTimersByTimeAsync(750) attempts.at(-1)?.onError(rpcRefusal(UNATTACHED)) - expect(applyError).toHaveBeenCalledWith(`RuntimeRpcCallError: ${UNATTACHED}`) + expect(applyError).toHaveBeenCalledWith(UNATTACHED) transport.dispose() } finally { vi.useRealTimers() @@ -284,7 +284,7 @@ describe('structured agent-session read transport unattached refusals', () => { throw new Error('journal read failed') }, applyError) await flushPromises() - expect(applyError).toHaveBeenCalledExactlyOnceWith('Error: journal read failed') + expect(applyError).toHaveBeenCalledExactlyOnceWith('journal read failed') transport.dispose() } finally { vi.useRealTimers() @@ -303,7 +303,32 @@ describe('structured agent-session read transport unattached refusals', () => { expect(applyError).not.toHaveBeenCalled() attempts[0].onError({ code: 'runtime_error', message: 'transport died' }) - expect(applyError).toHaveBeenCalledOnce() + // The host's own words, never `[object Object]` (P2-04). + expect(applyError).toHaveBeenCalledExactlyOnceWith('transport died') + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('re-opens after a failed open and leaves the error once the conversation reads (P2-04)', async () => { + vi.useFakeTimers() + try { + const applyError = vi.fn() + const applyEvent = vi.fn() + const transport = startWithHydration(async () => undefined, applyError, applyEvent) + await flushPromises() + attempts[0].onError({ code: 'agent_session_journal_unreadable', message: 'disk full' }) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenCalledExactlyOnceWith('disk full') + + // The retry re-asks, and the host's open runs again: a fault that cleared now reads. + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) + attempts[1].onEvent(snapshot(1)) + await flushPromises() + expect(applyEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'snapshot' })) transport.dispose() } finally { vi.useRealTimers() diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index f4310cec8904..bc1f77b33999 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_UNATTACHED_READ_GRACE_MS, isUnattachedAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal' +import { agentSessionErrorText } from '../../../../shared/agent-session-error-text' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' @@ -69,8 +70,8 @@ export function startStructuredAgentSessionReadTransport(args: { * A read failure, reported to the pane only once it is one. * * A refusal saying this host holds no attached session is the retry loop's own subject, not a - * verdict: the reconnect below re-asks, and either the hold that attaches the session or the tab - * retirement that ends the pane resolves it. Painting the pane red inside that window turned an + * verdict: the reconnect below re-asks, and either the host opening the session or the tab + * retirement that ends the pane resolves it. Only an older host still refuses this way. Painting the pane red inside that window turned an * ordinary chat close into a `Could not load conversation` the user could do nothing about. * * A window, not a mute. An unattached read still refusing past the grace is no longer @@ -79,13 +80,13 @@ export function startStructuredAgentSessionReadTransport(args: { const reportReadFailure = (error: unknown): void => { if (!isUnattachedAgentSessionReadRefusal(error)) { clearUnattachedReadGrace() - args.applyError(String(error)) + args.applyError(agentSessionErrorText(error)) return } const now = Date.now() unattachedSince ??= now if (now - unattachedSince >= AGENT_SESSION_UNATTACHED_READ_GRACE_MS) { - args.applyError(String(error)) + args.applyError(agentSessionErrorText(error)) } } const captureHistoryReadGuard = (): (() => boolean) => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts index 628a369281bb..f484f500a56f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts @@ -8,6 +8,9 @@ // // The release CHAINS off the hold rather than racing it: an unmount during the hold's round trip // would otherwise release a hold that has not landed yet, and the late hold would never be undone. +// +// A current host ignores both: it starts an agent only for a send. They stay for older hosts, until +// MIN_COMPATIBLE_RUNTIME_SERVER_VERSION passes the first host that ignores them. import { useEffect, useRef } from 'react' import { structuredAgentSessionHolderId } from '../../../../shared/structured-agent-session-holder' diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b5e8431c5738..650b98f7829a 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2672,6 +2672,7 @@ "title": "Start a chat with {{value0}}" }, "error": { + "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it.", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.", "title": "Could not load conversation" }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 41d65467935f..77f36e192aac 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17631,7 +17631,8 @@ }, "error": { "title": "Could not load conversation", - "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." + "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.", + "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it." }, "pairHost": "Pair a host to view agent chat history.", "notAgent": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de080f65c9ca..e21533f93de4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14812,7 +14812,8 @@ }, "error": { "title": "No se pudo cargar la conversación", - "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando." + "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando.", + "retryingSubtitle": "No se pudo leer la transcripción. Orca sigue intentando cargarla." }, "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 14877b0eef52..efb1e4ab6259 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17478,7 +17478,8 @@ }, "error": { "title": "Impossible de charger la conversation", - "subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler." + "subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler.", + "retryingSubtitle": "La transcription n'a pas pu être lue. Orca continue d'essayer de la charger." }, "pairHost": "Associez un hôte pour consulter l'historique de chat des agents.", "notAgent": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c36c326355c5..4da39809444e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17414,7 +17414,8 @@ }, "error": { "title": "会話を読み込めませんでした", - "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。" + "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。", + "retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。" }, "pairHost": "ホストをペアリングして、Agent のチャット履歴を表示します。", "notAgent": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3d9cc81c3fe4..5d8f65bda521 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17414,7 +17414,8 @@ }, "error": { "title": "대화를 로드할 수 없습니다.", - "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요." + "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요.", + "retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다." }, "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dc63c5370ba8..c84ac83960d4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17379,7 +17379,8 @@ }, "error": { "title": "无法加载对话", - "subtitle": "无法读取记录。请切回终端继续工作。" + "subtitle": "无法读取记录。请切回终端继续工作。", + "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。" }, "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts index 2797648afd0f..3ef7b1a19025 100644 --- a/src/shared/native-chat-empty-state.ts +++ b/src/shared/native-chat-empty-state.ts @@ -19,6 +19,11 @@ export const NATIVE_CHAT_EMPTY_STATE_COPY = { title: 'Could not load conversation', subtitle: 'The transcript could not be read. Toggle back to the terminal to keep working.' }, + // The structured chat's read reconnects on its own; the terminal-backed read does not. + retryingError: { + title: 'Could not load conversation', + subtitle: 'The transcript could not be read. Orca keeps trying to load it.' + }, notAgent: { title: 'No conversation here', subtitle: 'This terminal is not running a recognized coding agent.' 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 9a048d099683..884b5befdbfc 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 @@ -798,11 +798,11 @@ describe('cross-version structured agent sessions', () => { result: { value: { submission: { dispatchState: 'pending', handoverRecorded: true } } } }) open() - await vi.waitFor(() => + await vi.waitFor(async () => expect( - restarted - .journalSnapshot(SESSION) - .submissions.every((row) => row.dispatchState !== 'pending' || row.handedOverAt) + (await restarted.journalSnapshot(SESSION)).submissions.every( + (row) => row.dispatchState !== 'pending' || row.handedOverAt + ) ).toBe(true) ) }) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index d8c8b80b3293..f827d3cda5f7 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -115,7 +115,8 @@ export const STRUCTURED_CALLS: { hostMethod: 'revealSession', result: { ok: true, sessionId: SESSION, workspaceId: WORKSPACE, agent: 'codex', readable: true } }, - { method: 'agentSession.hold', hostMethod: 'hold', result: { held: true } }, + // A no-op on a host that starts an agent only for work; it still builds the host. + { method: 'agentSession.hold', hostMethod: null, result: { held: true } }, // The restart-resume surface. Bare additions, not capability-negotiated: an RPC method's // absence is explicit (`method_not_found`), which the old-dispatcher case below asserts, so a // newer client learns it during negotiation instead of by being met with silence. @@ -129,17 +130,14 @@ export const STRUCTURED_CALLS: { hostMethod: 'restartResumableDismiss', result: { dismissed: 0 } }, - { - method: 'agentSession.restartResume', - hostMethod: 'restartResumeAll', - result: { results: [] } - }, + // Reattaching alone is nothing now, so this answers that nothing was resumed. + { method: 'agentSession.restartResume', hostMethod: null, result: { results: [] } }, { method: 'agentSession.restartContinue', hostMethod: 'restartContinueAll', result: { resumed: [], continued: [] } }, - { method: 'agentSession.release', hostMethod: 'release', result: { released: true } }, + { method: 'agentSession.release', hostMethod: null, result: { released: true } }, { method: 'agentSession.history', hostMethod: 'history', From a59eed9da61ace6a2c16bd1066ac2747d75fe200 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:20:45 -0700 Subject: [PATCH 049/101] test(native-chat): type the queued-message fixtures in the resume-offer tests --- .../structured-agent-session-restart-resume.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 5cfd8012fe1d..7a8463d2fb9e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -327,7 +327,7 @@ describe('deriving what was working at teardown', () => { // Accepted while the agent was starting and never handed over: the chat shows working, but no // agent had the message, and quit rejects it as never sent. it('marks nothing for a session whose only work is a message still queued', () => { - const queued = { ...submission('msg-1', 'pending'), handoverRecorded: true } + const queued = { ...submission('msg-1', 'pending'), handoverRecorded: true as const } expect( markersAtTeardown({ sessions: new Map([ @@ -351,10 +351,10 @@ describe('deriving what was working at teardown', () => { it('marks a message handed over and not yet answered, and a turn a queued one waits behind', () => { const handedOver = { ...submission('msg-1', 'pending'), - handoverRecorded: true, + handoverRecorded: true as const, handedOverAt: NOW } - const queued = { ...submission('msg-2', 'pending'), handoverRecorded: true } + const queued = { ...submission('msg-2', 'pending'), handoverRecorded: true as const } const markers = markersAtTeardown({ sessions: new Map([ [ From ccaf412f96e899253d5732bcddd372ce7bcf7d6f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:20:47 -0700 Subject: [PATCH 050/101] fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. --- .../structured-agent-session-delivery-loop.ts | 29 +++++++++- .../structured-agent-session-host-types.ts | 4 ++ ...gent-session-provider-child-record.test.ts | 54 +++++++++++++++++++ ...structured-agent-session-provider-child.ts | 9 ++-- ...agent-session-startup-failure-exit.test.ts | 1 + ...ured-agent-session-unexpected-exit.test.ts | 8 ++- ...tructured-agent-session-unexpected-exit.ts | 3 +- 7 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 936fc639015c..da6c341ce872 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -129,9 +129,14 @@ export class StructuredAgentSessionDeliveryLoop { // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) - if (!oldestQueuedSubmission(session)) { + const oldest = oldestQueuedSubmission(session) + if (!oldest) { return this.stop(sessionId) } + const failedStart = startThatFailedWhileQueued(session, oldest) + if (failedStart) { + return this.fail(sessionId, failedStart) + } const ready = await this.deps.ensureProviderChild(sessionId) if (!ready.ok) { return ready @@ -192,7 +197,7 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } - private async fail(sessionId: string, failure: StartFailure): Promise { + private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { const session = this.deps.sessions.get(sessionId) if (session) { await recordStructuredAgentSessionStartFailure( @@ -210,6 +215,26 @@ export class StructuredAgentSessionDeliveryLoop { } } +/** A start that died while this message waited on it — a view's, say — is the message's failed + * start: settled with it, under its key, rather than started again into the same failure. */ +function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: NonNullable> +): StartFailure | null { + const ended = session.lastEndedChild + if ( + session.child || + !ended?.duringStartup || + ended.cause === 'user-stop' || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + return { startKey: ended.generation, text: endedChildRejection(ended) } +} + const HOST_STOPPED_BEFORE_DELIVERY = 'Orca stopped the agent before this message was sent.' /** Why a queued message the child never took is rejected, in the words the chat row uses. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 5a7161949c8a..44611300c7c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,4 +1,5 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -61,6 +62,9 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ reason: string | null duringStartup: boolean + /** Where the conversation's journal stood when the child ended, to order the end against a + * message's acceptance. */ + endedAt: AgentJournalCursor } /** The conversation: its journal, params and readers outlive any child that serves it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 51f74871dc28..fa252d2ce4f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -363,6 +363,60 @@ describe('a published child that dies while it proves its start', () => { ) }) +describe("a view's start that dies while a sent message waits on it", () => { + const EXIT = 'claude stream-json exited (code 1)' + const TEXT = providerStartupFailureOutcome(EXIT) + + it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { + adapterExtras = { awaitStarted: vi.fn(async () => TEXT) } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + // Opening the tab: the view's hold starts a child that has not proven its start. + await host.hold(SESSION, 'surface-1') + const viewChild = currentChild() + const events = subscribe() + const params = sendParams('hello') + + // Accepted first; the view's child's exit is settled before the loop's first step. + const sent = host.send(CALLER, params) + const exited = exit(viewChild, EXIT, true) + expect(await sent).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + await exited + const id = params.envelope.clientOperationId + + await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + await settleLoop() + expect(submission(id)?.reason).toBe(TEXT) + expect(statusRows()).toEqual([ + { + itemId: `orca:${encodeURIComponent(`start-failure:${viewChild.acquisitionGeneration}`)}`, + text: TEXT, + tone: 'error' + } + ]) + expect(rejectedIn(events, id)).toBe(true) + // The setup's child and the view's: nothing started again into the same failure. + expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('leaves a message sent after that start failed to a fresh start (R2)', async () => { + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + await host.hold(SESSION, 'surface-1') + await exit(currentChild(), EXIT, true) + expect(statusRows()).toHaveLength(1) + + const id = await accept('after the failure') + + await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + expect(acquire).toHaveBeenCalledTimes(3) + }) +}) + describe('a child that ends before its message is handed over', () => { it('starts one child for the message, then rejects it and stops (R2)', async () => { // The child the loop starts exits between its start step and its handover step. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 56be117bc4a9..1a792e600b49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -6,6 +6,7 @@ // and fence, so an ending that arrives late for an older child cannot end a newer one. import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, @@ -13,7 +14,9 @@ import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -type ChildBearer = Pick +type ChildBearer = Pick & { + journal: Pick +} /** The fence a conversation write carries: the record's, which is where the next child starts. A * child's own writes carry `child.fence`, which equals it while that child holds the lease. */ @@ -45,13 +48,13 @@ export function markProviderChildStarted( export function endProviderChild( session: ChildBearer, - ended: StructuredAgentSessionEndedChild + ended: Omit ): boolean { if (!matchingChild(session, ended)) { return false } session.child = null - session.lastEndedChild = ended + session.lastEndedChild = { ...ended, endedAt: session.journal.cursor() } return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index ab2c8f0b9e80..88774c63bedc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -21,6 +21,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { return { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index fd04a946aa30..1dc71d3a70a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -40,7 +40,8 @@ function recoveryContext(input: { cause: 'exit', reason: null, duringStartup: false, - rootGone: true + rootGone: true, + endedAt: { epoch: 'epoch-1', sequence: 0 } } } const record = { @@ -137,6 +138,7 @@ describe('provider-exit recovery tickets', () => { const session = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), @@ -206,6 +208,7 @@ describe('provider-exit recovery tickets', () => { const session = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -311,6 +314,7 @@ describe('provider-exit recovery tickets', () => { const session: StructuredAgentSessionUnexpectedExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -366,6 +370,7 @@ describe('provider-exit recovery tickets', () => { const session: StructuredAgentSessionUnexpectedExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -412,6 +417,7 @@ describe('provider-exit recovery tickets', () => { const session: StructuredAgentSessionUnexpectedExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index f30521ab8077..4874716d8cc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,3 +1,4 @@ +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { endProviderChild } from './structured-agent-session-provider-child' @@ -29,7 +30,7 @@ export type StructuredAgentSessionRecoveryTicket = { export type StructuredAgentSessionUnexpectedExitSession = Pick< StructuredAgentSessionHostSession, 'child' | 'lastEndedChild' -> & { journal: DeadGenerationJournal } +> & { journal: DeadGenerationJournal & Pick } export type StructuredAgentSessionUnexpectedExitContext< TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession From 0efca9330d458c26630ca1ac354ee9da47f67168 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:40:17 -0700 Subject: [PATCH 051/101] docs(native-chat): say what an attach's open conversation and unconfirmed ids are now --- .../agent-session-wire/structured-agent-session-attach.ts | 2 +- src/shared/agent-session-wire.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index e1ddea2c3259..383aaaeb3192 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -182,7 +182,7 @@ export async function attachJournal(input: { params: AgentSessionAttachParams journalRoot: string adapter: StructuredAgentSessionAdapter - /** The host's open conversation; omitted, the journal is opened for this caller alone. */ + /** The host's open conversation, whose journal the attach adopts. */ openConversation: (record: AgentSessionRecord) => Promise /** Provider history sampled before a new child is acquired. `null` means the * adapter had no usable history; omit to read lazily for direct callers. */ diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 38849ab0f05c..802417829104 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -295,7 +295,7 @@ export type AgentSessionAttachResult = { sessionId: string fence: number page: AgentSessionHistoryPage - /** Submissions the crash boundary settled as `unknown` while attaching. */ + /** Submissions a crash boundary left `unknown` that provider history could not decide. */ unconfirmedClientMessageIds: string[] /** The host-owned id of the tab that shows this chat. Absent from hosts that predate it. */ tabId?: string From 74d65061673dde20721010319632964a41288161 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:21:28 -0700 Subject: [PATCH 052/101] test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. --- ...gent-session-provider-child-record.test.ts | 35 +++++++++++++++++++ ...tured-agent-session-restart-resume.test.ts | 4 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index fa252d2ce4f7..bf3562282a70 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -415,6 +415,41 @@ describe("a view's start that dies while a sent message waits on it", () => { await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) expect(acquire).toHaveBeenCalledTimes(3) }) + + it('starts again for a message a proven child died under: only a start failed (R2)', async () => { + await restartHost() + await host.hold(SESSION, 'surface-1') + const params = sendParams('hello') + + const sent = host.send(CALLER, params) + const exited = exit(currentChild(), 'codex app-server crashed') + await sent + await exited + const id = params.envelope.clientOperationId + + await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + expect(acquire).toHaveBeenCalledTimes(3) + }) + + it('waits on a child started since the failed one, not on the failure (R2)', async () => { + adapterExtras = { awaitStarted: vi.fn(async () => undefined) } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + await host.hold(SESSION, 'surface-1') + const params = sendParams('hello') + + // A second view's start lands after the first child's exit, before the loop's first step. + const sent = host.send(CALLER, params) + const exited = exit(currentChild(), EXIT, true) + const held = host.hold(SESSION, 'surface-2') + await sent + await exited + await held + const id = params.envelope.clientOperationId + + await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + expect(acquire).toHaveBeenCalledTimes(3) + }) }) describe('a child that ends before its message is handed over', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 7a8463d2fb9e..5dc939d7e66a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -348,7 +348,7 @@ describe('deriving what was working at teardown', () => { ).toEqual([]) }) - it('marks a message handed over and not yet answered, and a turn a queued one waits behind', () => { + it('marks a handed-over message over a newer queued one, and a turn a queued one waits behind', () => { const handedOver = { ...submission('msg-1', 'pending'), handoverRecorded: true as const, @@ -360,7 +360,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-0', 'completed')], false, [handedOver]), + journal: journal([turnItem('turn-0', 'completed')], false, [handedOver, queued]), child: { fence: 1 } } ], From 9c671f1983b2cdee0dae90cc1d130fb6f38a0a77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:30:45 -0700 Subject: [PATCH 053/101] test(native-chat): the failed-start pins fail on what the message became, not on a timeout --- ...structured-agent-session-provider-child-record.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index bf3562282a70..5abe04e8552b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -416,7 +416,7 @@ describe("a view's start that dies while a sent message waits on it", () => { expect(acquire).toHaveBeenCalledTimes(3) }) - it('starts again for a message a proven child died under: only a start failed (R2)', async () => { + it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { await restartHost() await host.hold(SESSION, 'surface-1') const params = sendParams('hello') @@ -427,7 +427,8 @@ describe("a view's start that dies while a sent message waits on it", () => { await exited const id = params.envelope.clientOperationId - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(() => expect(submission(id)?.dispatchState).not.toBe('pending')) + expect(submission(id)?.dispatchState).toBe('accepted') expect(acquire).toHaveBeenCalledTimes(3) }) @@ -447,7 +448,8 @@ describe("a view's start that dies while a sent message waits on it", () => { await held const id = params.envelope.clientOperationId - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(() => expect(submission(id)?.dispatchState).not.toBe('pending')) + expect(submission(id)?.dispatchState).toBe('accepted') expect(acquire).toHaveBeenCalledTimes(3) }) }) From eaf1436490424b74e7cc1da722b33f21d63709cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:42:31 -0700 Subject: [PATCH 054/101] fix(native-chat): a restart offer ends when the chat's agent starts again The offer used to end only when the chat's newest user message changed, because opening a chat started its agent and that start could not be told apart from real activity. Opening a chat starts nothing now, so the host reads the fact it already publishes: a chat's status row goes from not host-owned to host-owned exactly when its agent is started. At that edge the offer and any failure record for the chat are withdrawn, unless the start is a resume action's own (its continuation is the oldest undelivered message). A continuation and a message racing to be first are decided at acceptance: the continuation is refused, quietly and with nothing filed, when any other message was accepted since the restart. A failed continuation start leaves the offer retryable, and each resume action sends its own message id. Deleted: the newest-user-message comparison, its journal reader, the continuation filter, and the failure ledger's own "answered by the chat" check. The marker still carries its message id for one release, so the previous build can read it. --- ...tructured-agent-session-client-delivery.ts | 10 +- ...d-agent-session-conversation-close.test.ts | 51 ++- ...red-agent-session-conversation-lifetime.ts | 1 + ...ctured-agent-session-host-teardown.test.ts | 2 +- .../structured-agent-session-host-teardown.ts | 3 +- .../structured-agent-session-host.ts | 12 +- ...ctured-agent-session-restart-candidates.ts | 58 +-- ...ructured-agent-session-restart-cap.test.ts | 3 +- ...d-agent-session-restart-child-work.test.ts | 72 +--- ...t-session-restart-continuation-envelope.ts | 63 +++ ...agent-session-restart-continuation.test.ts | 25 +- ...ured-agent-session-restart-continuation.ts | 85 +--- ...ent-session-restart-failure-filing.test.ts | 170 +------- ...ed-agent-session-restart-failure-ledger.ts | 144 +------ ...ssion-restart-interruption-test-harness.ts | 41 +- ...ssion-restart-offer-after-reattach.test.ts | 122 ++---- ...gent-session-restart-offer-endings.test.ts | 12 +- ...t-session-restart-offer-withdrawal.test.ts | 283 +++++++++++++ ...-agent-session-restart-offer-withdrawal.ts | 89 ++++ ...ed-agent-session-restart-ownership.test.ts | 388 ++++-------------- ...tured-agent-session-restart-resume-host.ts | 98 ++--- ...ctured-agent-session-restart-resume-set.ts | 25 +- ...ent-session-restart-resume-test-harness.ts | 4 +- ...red-agent-session-restart-resume-wiring.ts | 24 +- ...tured-agent-session-restart-resume.test.ts | 18 +- .../structured-agent-session-status-feed.ts | 9 +- src/shared/agent-session-resume-marker.ts | 12 +- 27 files changed, 814 insertions(+), 1010 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts 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 06bda8203828..48f1f5f6e82e 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 @@ -27,9 +27,15 @@ export class StructuredAgentSessionClientDelivery { private readonly sessions: Map, now: () => number, deps: () => StructuredAgentSessionHostDeps, - private readonly onJournalActivity?: (sessionId: string) => void + private readonly onJournalActivity?: (sessionId: string) => void, + onOwnedEdge?: (sessionId: string) => void ) { - this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps }) + this.statusFeed = createStructuredAgentSessionHostStatusFeed({ + sessions, + now, + deps, + ...(onOwnedEdge ? { onOwnedEdge } : {}) + }) this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 835dcbd7be14..9c27dfd7e438 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -2,6 +2,8 @@ // list and a send each see only the one that happened. Ticks are driven by hand. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -23,6 +25,8 @@ import { hostTestAttachParams } from './structured-agent-session-host-test-data' let rig: RestTestRig +const COLD_START = { timeout: 10_000 } + beforeEach(async () => { rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) setStructuredAgentSessionHost(rig.host) @@ -55,7 +59,9 @@ describe('a stop that fails', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(openSession()?.owesProviderChildWindDown).toMatchObject({ generation: expect.any(String) }) + expect(openSession()?.owesProviderChildWindDown).toMatchObject({ + generation: expect.any(String) + }) await sweepOnce(rig.host) expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) @@ -104,7 +110,8 @@ describe('closing the handle', () => { const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + // A cold start: reopen, reconcile and acquire. + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) // The reader opened on the first handle is fed by the second. await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) }) @@ -172,7 +179,8 @@ describe('closing the handle', () => { // And the handle a write finds is a live one. const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + // A cold start: reopen, reconcile and acquire. + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) }) it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { @@ -223,9 +231,12 @@ describe('the sweep and the lease (P2-20)', () => { }) describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, its queued message rejected with why, and the chat still sends', async () => { + it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { + const stopReason = 'Codex never finished starting, so Orca stopped it.' + const order: string[] = [] const started = Promise.withResolvers() rig.adapter.closeSession.mockImplementation(async () => { + order.push('stopped') started.resolve() return true }) @@ -234,8 +245,17 @@ describe('a start that never finishes (P2-15)', () => { ...(await spawn(input)), providerChildPhase: 'starting' as const })) - Object.assign(rig.host.deps.adapter, { - awaitStarted: () => started.promise + Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) + const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions + vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions').mockImplementation(function ( + this: AgentSessionJournal, + ...args + ) { + // Not the open's sweep of an earlier process's leftovers. + if (args[1] !== DISPATCH_REJECTED_HOST_RESTARTED) { + order.push(`rejected: ${args[1]}`) + } + return reject.apply(this, args) }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) @@ -249,17 +269,26 @@ describe('a start that never finishes (P2-15)', () => { expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( - expect.objectContaining({ - dispatchState: 'rejected', - reason: 'Codex never finished starting, so Orca stopped it.' - }) + expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) ) ) + // One rejection, written after the stop by the loop, with the stop's own words. + expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) + const errorRows = reader.events.flatMap((event) => + event.type === 'batch' + ? event.batch.items.filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + : [] + ) + expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ + stopReason + ]) expect(rig.adapter.dispatch).not.toHaveBeenCalled() const again = await rig.host.send(CALLER, restTestSend('try again', fence())) expect(again.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), COLD_START) await expect(sweepOnce(rig.host)).resolves.toBeUndefined() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index a71fa103cbb8..963917c45631 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -80,6 +80,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { return { idleSweep, stopAgent, + isDisposed: (): boolean => disposed, /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index 5fd0440a1fe1..d82910bff378 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -13,8 +13,8 @@ describe('structured agent-session host teardown', () => { recordResumeMarkers: noop }) expect(phases.map((phase) => phase.name)).toEqual([ - 'begin-resume-markers', 'dispose-idle-sweep', + 'begin-resume-markers', 'stop-lease-renewal', 'drain-attaches', 'evict-owned-sessions', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 33149381c81d..175599608753 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -59,6 +59,8 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { recordResumeMarkers: () => Promise }): StructuredAgentSessionTeardownPhase[] { return [ + // First: once quit begins, a start that lands must not withdraw an offer teardown captures. + { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'begin-resume-markers', run: () => { @@ -69,7 +71,6 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { } } }, - { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { 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 4f2547f027ff..909d0ed4c004 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 @@ -74,7 +74,8 @@ export class StructuredAgentSessionHost { this.sessions, () => this.now(), () => this.deps, - (sessionId) => this.sessions.touch(sessionId) + (sessionId) => this.sessions.touch(sessionId), + (sessionId) => this.restartResume.onOwnedEdge(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -151,11 +152,10 @@ export class StructuredAgentSessionHost { now: () => this.now(), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) - this.restartResume = createStructuredAgentSessionRestartResume( - deps, - this.sessions, - structuredAgentSessionRestartResumeSurfaces(this, this.now) - ) + this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { + ...structuredAgentSessionRestartResumeSurfaces(this, this.now), + isDisposed: () => this.lifetime.isDisposed() + }) this.lifetime = createStructuredAgentSessionConversationLifetime({ context: () => this.lifetimeContext(), sessions: this.sessions, 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 46879aefe49a..a9251cd2824e 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 @@ -2,16 +2,11 @@ // // A different question from storage: the durable record decides which markers are still present; // this decides which of those a resume may act on. The offer, the click and the pre-send check all -// ask it, and all get the same answer: the marker stands until the user sends a newer message. +// ask it, and all get the same answer. -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { - latestStructuredAgentSessionPrompt, - latestStructuredAgentSessionUserItem -} from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' @@ -23,61 +18,26 @@ import { /** The only part of a live session this reads. */ export type StructuredAgentSessionRestartJournalSource = { journal: AgentSessionJournal } -export type StructuredAgentSessionRestartCandidateOptions = { - /** A continuation already in flight; its own submission is not newer user work. */ - pendingContinuationId?: string -} - export type StructuredAgentSessionRestartCandidateReader = ( markers: readonly AgentSessionResumeMarker[], - leaseState: 'must-be-released' | 'may-be-held', - options?: StructuredAgentSessionRestartCandidateOptions + leaseState: 'must-be-released' | 'may-be-held' ) => StructuredAgentSessionResumableSet -/** The newest user message in a live session's journal, the same fact the predicate compares - * against a marker. Undefined when the session is not readable here, which decides nothing. */ -export function liveStructuredAgentSessionLatestUserItemId( - sessions: ReadonlyMap, - sessionId: string -): string | null | undefined { - const session = sessions.get(sessionId) - return session - ? (latestStructuredAgentSessionUserItem(session.journal.snapshot().items)?.itemId ?? null) - : undefined -} - export function createStructuredAgentSessionRestartCandidateReader(deps: { /** The host's live session map; a marker's chat is readable once listing has revealed it. */ sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter }): StructuredAgentSessionRestartCandidateReader { - return (markers, leaseState, options = {}) => { - const items = new Map() - const itemsFor = (sessionId: string): AgentJournalRenderItem[] | undefined => { - if (items.has(sessionId)) { - return items.get(sessionId) - } - let snapshot = deps.sessions.get(sessionId)?.journal.snapshot().items - if (snapshot && options.pendingContinuationId) { - const ownItemId = agentJournalSubmissionKey(options.pendingContinuationId) - snapshot = snapshot.filter((item) => item.itemId !== ownItemId) - } - items.set(sessionId, snapshot) - return snapshot - } - return structuredAgentSessionResumableSet({ + return (markers, leaseState) => + structuredAgentSessionResumableSet({ markers, getRecord: deps.getRecord, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt(itemsFor(sessionId) ?? []), - latestUserItemId: (sessionId) => { - const snapshot = itemsFor(sessionId) - return snapshot === undefined - ? undefined - : (latestStructuredAgentSessionUserItem(snapshot)?.itemId ?? null) - }, + latestPrompt: (sessionId) => + latestStructuredAgentSessionPrompt( + deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] + ), leaseState }) - } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts index fc5c25dea0a5..9ded5b54d983 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -47,7 +47,8 @@ it('holds each slot from accept until handover or rejection, and frees it when t const started = await startStructuredAgentSessionContinuation( deps(sessionId), sessionId, - marker() + marker(), + 'operation-1' ) if ('done' in started && started.done.outcome === 'refused') { throw new Error(started.done.reason ?? 'refused') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts index a7f91be54a0b..6d8514c830e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts @@ -1,14 +1,8 @@ // A chat whose main agent had finished while its subagents still ran, across a restart. import { expect, it, vi } from 'vitest' -import { - interruptedRestart, - startAgent -} from './structured-agent-session-restart-interruption-test-harness' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD -} from './structured-agent-session-host-test-data' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' // What the user hit: the lead had finished, its subagent had not. The offer names the subagent // from the roster snapshot taken BEFORE the child stopped — the close that follows settles the @@ -24,66 +18,22 @@ it('offers a chat whose subagent the restart stopped, named from the stop-time s ]) }) -// On resume the provider restates what it lost in its own words, rewriting the row before the -// continuation is dispatched. What the offer was admitted for still stands. -it('continues a stopped subagent after the provider restates its row', async () => { - const state = await interruptedRestart('children') - const { host, acquire, dispatch } = state - await startAgent(state) - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const admit = vi.spyOn(host.deps.store, 'admitMutationOperation') - admit.mockImplementationOnce(async (input) => { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) - admit.mockRestore() - return host.deps.store.admitMutationOperation(input) - }) +// The offer was admitted for the subagent's work; the continuation asks the agent to carry it on. +it('continues a chat whose subagent the restart stopped', async () => { + const { host, dispatch } = await interruptedRestart('children') expect( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) }) -// The user opens the chat before choosing Resume, and the reattached provider restates its row. +// The user opens the chat before choosing Resume: reading it starts nothing, so the offer stands. it('still offers and continues a chat the user opened before resuming', async () => { - const state = await interruptedRestart('children') - const { host, acquire, dispatch } = state - await startAgent(state) - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) + const { host, acquire, dispatch } = await interruptedRestart('children') + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + unsubscribe() + expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toMatchObject([ { sessionId: SESSION, activity: { tasks: [{ kind: 'agent', label: 'Review loop 4' }] } } ]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts new file mode 100644 index 000000000000..56a071eb9253 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts @@ -0,0 +1,63 @@ +// The restart continuation as a message: its body, which depends on the marker alone, and its +// identity, which is one per resume action. + +import { createHash } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { restartContinuationMessage } from '../../../shared/agent-session-restart-continuation' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' + +/** The message body, built once so both the send and any test read the same text. */ +export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: restartContinuationMessage(marker) }] + } +} + +/** One resume action's continuation: the same action sends it once, and a retry is a new action + * with a new message, never a replay of the one that failed. */ +export function restartContinuationId( + sessionId: string, + marker: AgentSessionResumeMarker, + operationId: string +): string { + return `${marker.recordedAt.toString().padStart(13, '0')}-${createHash('sha256') + .update( + JSON.stringify([ + marker.teardownId, + sessionId, + marker.work.kind, + marker.work.id, + marker.providerHandleRoot, + operationId + ]) + ) + .digest('hex') + .slice(0, 32)}` +} + +/** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ +export function restartContinuationEnvelope( + sessionId: string, + fence: number, + marker: AgentSessionResumeMarker, + operationId: string +): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { + const body = restartContinuationBody(marker) + return { + body, + envelope: { + sessionId, + clientOperationId: restartContinuationId(sessionId, marker, operationId), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 0172cdd69500..0fe36994cd7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -53,7 +53,7 @@ it('reports an accepted continuation and records its note', async () => { const deps = dependencies('accepted') await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'continued' @@ -75,20 +75,20 @@ it.each([ const deps = dependencies(settled) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual(expected) expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note) } ) -it('notes a superseded continuation in the chat and still reports the refusal', async () => { +it("writes nothing in the chat when the user's own message came first, and still refuses", async () => { const deps = dependencies('accepted') deps.send.mockRejectedValue(new RestartContinuationSupersededError()) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).rejects.toBeInstanceOf(RestartContinuationSupersededError) - expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) + expect(deps.note).not.toHaveBeenCalled() }) // An ownership refusal would meet the user's own message too, so the note gives no advice to send one. @@ -100,7 +100,7 @@ it.each([ deps.send.mockResolvedValue({ ok: false, refusal: { code } }) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', @@ -115,7 +115,7 @@ it('reports an unattached chat without sending', async () => { deps.currentFence = () => null await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', @@ -129,7 +129,7 @@ it('reports a continuation rejected at handover without waiting for the provider const deps = dependencies('accepted', 'rejected') await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', reason: 'Codex could not start.' }) expect(deps.awaitSettlement).not.toHaveBeenCalled() expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) @@ -139,7 +139,12 @@ it('reports a continuation rejected at handover without waiting for the provider it('returns at handover and leaves the provider verdict to be awaited', async () => { const deps = dependencies('accepted') - const started = await startStructuredAgentSessionContinuation(deps, SESSION, marker()) + const started = await startStructuredAgentSessionContinuation( + deps, + SESSION, + marker(), + 'operation-1' + ) expect(deps.awaitHandedOver).toHaveBeenCalledOnce() expect(deps.awaitSettlement).not.toHaveBeenCalled() @@ -156,7 +161,7 @@ it('still awaits the verdict when the handover wait ends unanswered', async () = const deps = dependencies('unknown', undefined) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'unknown' }) expect(deps.note).toHaveBeenCalledExactlyOnceWith(...UNCONFIRMED) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 61009b451501..4277704131eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -13,16 +13,14 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, - restartContinuationMessage + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' -import { createHash } from 'node:crypto' +import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation-envelope' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' /** @@ -64,13 +62,9 @@ export type StructuredAgentSessionContinuationHost = { ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void now: () => number - /** Whether the marker is still an offer, with the continuation's own submission set aside. - * Re-asked right before dispatch, so a newer user message refuses the send; a provider turn - * running then does not, since both providers queue a message sent mid-turn. */ - stillResumable: ( - marker: AgentSessionResumeMarker, - options: { pendingContinuationId: string } - ) => boolean + /** Whether the marker is still an offer. Asked at acceptance, inside the session lock, so the + * first message accepted since the restart decides: the user's, or this continuation. */ + stillResumable: (marker: AgentSessionResumeMarker) => boolean } /** Binds one continuation to the host: the superseded check before dispatch, the settlement @@ -85,11 +79,7 @@ export function restartContinuationDeps( host.send({ ...input, beforeRun: () => { - if ( - !host.stillResumable(marker, { - pendingContinuationId: input.envelope.clientOperationId - }) - ) { + if (!host.stillResumable(marker)) { throw new RestartContinuationSupersededError() } } @@ -128,57 +118,17 @@ const OWNERSHIP_REFUSALS = new Set([ 'execution_owner_reconciling' ]) +/** The user's own message was accepted first; the offer is spent, and nothing failed. */ +export const RESTART_CONTINUATION_SUPERSEDED = 'agent_session_restart_work_superseded' + /** Only this pre-dispatch failure proves a thrown send did not deliver. */ export class RestartContinuationSupersededError extends AgentSessionPreDispatchError { constructor() { - super('agent_session_restart_work_superseded') + super(RESTART_CONTINUATION_SUPERSEDED) this.name = 'RestartContinuationSupersededError' } } -/** The message body, built once so both the send and any test read the same text. */ -export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { - return { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: restartContinuationMessage(marker) }] - } -} - -/** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ -export function restartContinuationEnvelope( - sessionId: string, - fence: number, - marker: AgentSessionResumeMarker -): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { - const body = restartContinuationBody(marker) - return { - body, - envelope: { - sessionId, - // The same interrupted work must reach the durable ledger with the same message identity. - clientOperationId: `${marker.recordedAt.toString().padStart(13, '0')}-${createHash('sha256') - .update( - JSON.stringify([ - marker.teardownId, - sessionId, - marker.work.kind, - marker.work.id, - marker.providerHandleRoot - ]) - ) - .digest('hex') - .slice(0, 32)}`, - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) - } - } -} - type ContinuationSubmission = { dispatchState?: string; reason?: string | null } export type StructuredAgentSessionContinuationDeps = { @@ -231,13 +181,17 @@ export type StartedStructuredAgentSessionContinuation = export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker + marker: AgentSessionResumeMarker, + operationId: string ): Promise { let started: StartedStructuredAgentSessionContinuation try { - started = await sendContinuation(deps, sessionId, marker) + started = await sendContinuation(deps, sessionId, marker, operationId) } catch (error) { - await noteNotContinued(deps, sessionId, 'refused') + // The user's own message came first: nothing failed, so the chat says nothing. + if (!(error instanceof RestartContinuationSupersededError)) { + await noteNotContinued(deps, sessionId, 'refused') + } throw error } if ('done' in started) { @@ -305,13 +259,14 @@ async function noteNotContinued( async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker + marker: AgentSessionResumeMarker, + operationId: string ): Promise { const fence = deps.currentFence(sessionId) if (fence === null) { return { done: { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } } } - const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker) + const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker, operationId) const sent = await deps.send({ envelope, body }).catch((error: unknown) => { if (error instanceof AgentSessionPreDispatchError) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index afbf0dc37b98..f1fe0a40b421 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -4,19 +4,16 @@ import { AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, - startAgent, statusNotes } from './structured-agent-session-restart-interruption-test-harness' -import { hostTestState } from './structured-agent-session-host-test-harness' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' @@ -24,75 +21,24 @@ import { afterEach(() => vi.restoreAllMocks()) -function providerEvents(acquire: Awaited>['acquire']) { - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - return events -} - -// The reported case: after the reattach the provider replays a queued message, which the chat -// journals as a newer user turn, so the continuation is refused just before dispatch. -it('files a continuation superseded by a replayed message, lists it and says so in the chat', async () => { - const { host, acquire, dispatch, root } = await interruptedRestart() - await host.restartResume.list() - await startAgent(hostTestState()) - const events = providerEvents(acquire) - // The replay lands after the reattach and just before the continuation is accepted, which is - // where a send asks whether its offer still stands. - const send = host.send - vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'replayed-turn', ordinal: 1 }, - hostTestMessage('A queued notification the provider replayed') - ) - await host.flushStreamedEvents(SESSION) - return send(caller, params) - }) - - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect(dispatch).not.toHaveBeenCalled() - expect(result.resumed).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - const failure = { - sessionId: SESSION, - outcome: 'refused', - reason: 'agent_session_restart_work_superseded' - } - expect(result.failed).toMatchObject([failure]) - expect(await host.restartResume.listFailures()).toMatchObject([failure]) - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toHaveLength(1) - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' - }) -}) - -// Nothing was attempted and nothing is owed: the user moved on between listing and acting. +// Nothing was owed once the user's own message came first: the offer is spent and nothing is filed. it('files nothing for a chat the user moved on in before its attempt, and spends the offer', async () => { - const { host, acquire, root } = await interruptedRestart() + const { host, root, dispatch } = await interruptedRestart() await host.restartResume.list() - await startAgent(hostTestState()) - const events = providerEvents(acquire) const admit = StructuredAgentSessionResumeAdmission.prototype.run vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run').mockImplementationOnce( async function (this, ...args) { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - await host.flushStreamedEvents(SESSION) + const body = hostTestMessage('A newer task from another client') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) return admit.apply(this, args) } ) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.resumed).toMatchObject([{ reason: 'agent_session_resume_not_eligible' }]) + expect(result.continued.filter((entry) => entry.outcome === 'continued')).toEqual([]) expect(result.failed).toEqual([]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) @@ -109,7 +55,7 @@ it('says so in the chat when the agent cannot start for the continuation', async await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(await host.restartResume.listFailures()).toMatchObject([ - { sessionId: SESSION, outcome: 'refused' } + { sessionId: SESSION, outcome: 'refused', retryable: true } ]) expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -117,107 +63,29 @@ it('says so in the chat when the agent cannot start for the continuation', async }) }) -/** A continuation the provider accepted whose settlement could not be written: filed unconfirmed. */ -async function unconfirmedContinuation() { - const state = await interruptedRestart() - const { host, store } = state +// A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, +// and it stays on record while the agent that may be carrying on keeps running. +it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { + const { host, store } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - await startAgent(hostTestState()) const settle = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { + if ( + input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && + input.outcome.status === 'succeeded' + ) { throw new Error('operation outcome could not be persisted') } return settle(input) }) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - const continuation = (await host.journalSnapshot(SESSION)).submissions.at(-1) - const providerItemId = continuation?.providerItemId - if (!continuation || !providerItemId) { - throw new Error('missing accepted continuation') - } - return { ...state, continuation, providerItemId, events: providerEvents(state.acquire) } -} - -// The warning asked the user to check the agent's reply; a turn opened by the continuation's own -// message is that reply starting, however the provider names the message. -it.each(['submission key', 'provider key'] as const)( - 'retires an unconfirmed failure once the continuation opens a turn (%s)', - async (naming) => { - const { host, root, continuation, providerItemId, events } = await unconfirmedContinuation() - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'continued-turn', ordinal: 1 }, - { - kind: 'turn', - turnId: 'continued-turn', - state: 'running', - userItemId: - naming === 'submission key' - ? agentJournalSubmissionKey(continuation.clientMessageId) - : providerItemId - }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.listFailures()).toEqual([]) - await vi.waitFor(async () => { - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) - }) - } -) - -it('keeps an unconfirmed failure while the newest turn is not the continuation’s', async () => { - const { host, events } = await unconfirmedContinuation() - // Provider output opened this turn, keyed by its own row rather than by any user message. - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'provider-turn', state: 'running', userItemId: 'provider-turn-row' }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) -}) - -// Nothing journaled the continuation, so the newest user message is still the interrupted one and -// the turn it opened is the interrupted work reporting in, not the agent carrying on. -it('keeps an unconfirmed failure whose continuation was never journaled while the interrupted turn runs', async () => { - const { host, acquire, store, marker } = await interruptedRestart('submission', false) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - await host.restartResume.list() - await startAgent(hostTestState()) - const events = providerEvents(acquire) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('Perform the original task') - ) - await host.flushStreamedEvents(SESSION) - // A send that throws before recording anything cannot be proven undelivered. - vi.spyOn(store, 'admitMutationOperation').mockRejectedValueOnce(new Error('store unavailable')) - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - const submissions = (await host.journalSnapshot(SESSION)).submissions - const original = submissions[0]?.providerItemId - if (submissions.length !== 1 || !original) { - throw new Error('expected only the original submission, accepted by the provider') - } - expect( - latestStructuredAgentSessionUserItem((await host.journalSnapshot(SESSION)).items)?.itemId - ).toBe(marker?.latestUserItemId) - - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, - { kind: 'turn', turnId: 'original-turn', state: 'running', userItemId: original }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) }) 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 3b8fad40de09..be20c3ef0d3a 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 @@ -4,9 +4,8 @@ // the offer, so without this record nothing durable would point at the chat the user has to // continue by hand. The capsule holds the record; this decides what goes in and when it leaves. // -// Whether a record is still current is derived, never cached: it is current only while the chat's -// newest user message is the one it had when the failure was filed. The user's own send, from any -// client, therefore retires it without a hook on the send path. +// A record ends when the chat's agent is started again outside a resume action — the host retires +// it at that start, with the offer — or by a successful retry, or a dismissal. import type { AgentSessionRecoveryCapsule, @@ -18,19 +17,10 @@ import type { AgentSessionResumeFailureOutcome, AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' -import { - liveStructuredAgentSessionLatestUserItemId, - type StructuredAgentSessionRestartJournalSource -} from './structured-agent-session-restart-candidates' import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import type { StructuredAgentSessionResumeCandidate, @@ -40,37 +30,18 @@ import { STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE, type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' +import { RESTART_CONTINUATION_SUPERSEDED } from './structured-agent-session-restart-continuation' type FailureCapsule = Pick< AgentSessionRecoveryCapsule, - | 'listFailed' - | 'completeResume' - | 'failResume' - | 'rollbackResume' - | 'dismiss' - | 'clearAll' - | 'forgetSuperseded' + 'listFailed' | 'completeResume' | 'failResume' | 'rollbackResume' | 'dismiss' | 'clearAll' > -/** What a failure is filed against: the chat's newest user message as its own attempt ended. Kept - * per chat rather than read at settlement, because the rest of a batch can take a while and a - * message the user sends meanwhile answers the failure rather than belongs to it. */ -export type StructuredAgentSessionRestartAttempts = { - observe: (sessionId: string) => void - latestUserItemId: (sessionId: string) => string | null -} - export type StructuredAgentSessionRestartFailureLedger = { /** The stored records, current or not. */ read: () => Promise - /** The current records as rows a surface can show; sessions this host no longer holds are left - * out, and records the chat has since superseded are dropped and pruned. */ + /** The records as rows a surface can show; sessions this build cannot run are left out. */ list: () => Promise - /** One action's attempts; a chat never observed after an attempt falls back to its reserved - * marker. */ - attempts: ( - markers: ReadonlyMap - ) => StructuredAgentSessionRestartAttempts /** Settles one operation's reservations: the agent carried on, or the failure is filed. Rows the * operation still owns after that are reopened. */ settle: ( @@ -78,7 +49,8 @@ export type StructuredAgentSessionRestartFailureLedger = { outcomes: readonly StructuredAgentSessionResumeOutcome[], action: { candidates: readonly StructuredAgentSessionResumeCandidate[] - attempts: StructuredAgentSessionRestartAttempts + /** The reserved markers; a failure carries its marker's message id for older readers. */ + markers: ReadonlyMap /** How a reattached session's action ended; null when the agent carried on. Reattaching alone * is not the whole action, so the runner's own outcome cannot decide this. */ failureAfterResume: (sessionId: string) => AgentSessionResumeFailureOutcome | null @@ -99,50 +71,8 @@ export function continuationFailureOutcome( return outcome === 'continued' ? null : outcome === 'refused' ? 'refused' : 'unconfirmed' } -/** Whether the chat itself has moved past a failure: a newer user message, or, for a delivery - * nobody confirmed, a turn the continuation's own message opened. */ -function failureAnsweredByChat( - failure: AgentSessionResumeFailureRecord, - snapshot: AgentJournalSnapshot -): boolean { - const latest = latestStructuredAgentSessionUserItem(snapshot.items)?.itemId ?? null - if (latest !== failure.latestUserItemId) { - return true - } - // Only when the continuation's message was journaled: otherwise the newest user message is the - // interrupted one, whose own turn proves nothing about the continuation. - return ( - failure.outcome === 'unconfirmed' && - latest !== null && - latest !== failure.marker.latestUserItemId && - newestTurnUserItemId(snapshot) === latest - ) -} - -/** The user message that opened the newest root turn, resolved through a provider key the send was - * accepted under. */ -function newestTurnUserItemId(snapshot: AgentJournalSnapshot): string | null { - for (let index = snapshot.items.length - 1; index >= 0; index -= 1) { - const item = snapshot.items[index] - const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item?.body) : null - if (!turn) { - continue - } - const key = turn.userItemId - if (key === undefined) { - return null - } - const accepted = snapshot.submissions.find((entry) => entry.providerItemId === key) - return accepted ? agentJournalSubmissionKey(accepted.clientMessageId) : key - } - return null -} - export function createStructuredAgentSessionRestartFailureLedger(deps: { capsule?: FailureCapsule - sessions: ReadonlyMap - /** Makes a persisted chat's journal readable here, as listing an offer does. */ - reveal: (sessionId: string) => Promise getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ @@ -189,37 +119,8 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { ] } - const list = async (): Promise => { - const current: AgentSessionResumeFailureRecord[] = [] - const superseded: AgentSessionResumeFailureRecord[] = [] - for (const failure of await read()) { - const sessionId = failure.marker.sessionId - if (!deps.sessions.has(sessionId)) { - await deps.reveal(sessionId) - } - const snapshot = deps.sessions.get(sessionId)?.journal.snapshot() - // An unreadable journal decides nothing; the record stays until something that can decide. - if (snapshot && failureAnsweredByChat(failure, snapshot)) { - superseded.push(failure) - } else { - current.push(failure) - } - } - const capsule = deps.capsule - if (capsule && superseded.length > 0) { - const gone = superseded.map((failure) => ({ - sessionId: failure.marker.sessionId, - recordedAt: failure.marker.recordedAt, - failedAt: failure.failedAt - })) - void deps - .enqueue(() => capsule.forgetSuperseded(gone, deps.now())) - .catch(() => { - console.warn('[structured-agent-session] pruning superseded restart failures failed') - }) - } - return current.flatMap(toRow) - } + const list = async (): Promise => + (await read()).flatMap(toRow) const settle: StructuredAgentSessionRestartFailureLedger['settle'] = async ( operationId, @@ -237,11 +138,12 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { ) for (const outcome of outcomes) { const resumed = outcome.outcome === 'resumed' - // Ineligible means the user moved on or the offer no longer applies (record gone, - // conversation forked), so there is nothing to retry and the offer is spent. + // Ineligible means the offer no longer applies (record gone, conversation forked), and + // superseded means the user's own message came first: nothing to retry, and the offer is spent. const failure = resumed ? action.failureAfterResume(outcome.sessionId) - : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE || + outcome.reason === RESTART_CONTINUATION_SUPERSEDED ? null : 'refused' if (failure === null) { @@ -256,7 +158,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { ? action.failureReason(outcome.sessionId) : (outcome.reason ?? 'agent_session_resume_refused'), latestPrompt: promptBySession.get(outcome.sessionId) ?? '', - latestUserItemId: action.attempts.latestUserItemId(outcome.sessionId) + latestUserItemId: action.markers.get(outcome.sessionId)?.latestUserItemId ?? null }) } await deps @@ -280,27 +182,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { }) } - const attempts: StructuredAgentSessionRestartFailureLedger['attempts'] = (markers) => { - const observed = new Map() - return { - // Observed after the attempt, so the continuation's own message is part of the filed state. - observe: (sessionId) => { - const latest = liveStructuredAgentSessionLatestUserItemId(deps.sessions, sessionId) - if (latest !== undefined) { - observed.set(sessionId, latest) - } - }, - latestUserItemId: (sessionId) => { - const latest = observed.get(sessionId) - return latest !== undefined ? latest : (markers.get(sessionId)?.latestUserItemId ?? null) - } - } - } - return { read, list, - attempts, settle, dismiss: (sessionIds, beforeClearAll) => deps.enqueue(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index de3102af23ac..d37e73aa90d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -130,7 +130,8 @@ export async function interruptedRestart( mintSpawnToken: () => 'spawn-next', probeOwner: async () => ({ outcome: 'pid-absent' }), recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), - now: () => NOW + // The relaunch comes after the quit that recorded the offer. + now: () => NOW + 1 }) replaceHostTestState({ store, host }) previous.acquire.mockClear() @@ -149,35 +150,25 @@ export async function statusNotes(host: StructuredAgentSessionHost) { ) } -/** A reattach that succeeds and a continuation the host refuses: a message from another client - * lands while the continuation is being recorded. `userAnswers` has the user reply in the chat - * just before or after its own attempt, while the rest of a batch would still be running. */ +/** A continuation the host refuses because the user's own message was accepted first: another + * client's send lands after the action reserved the offer, just before the continuation is + * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ export async function supersededRefusal(userAnswers?: 'before' | 'after') { const { host, store, acquire, dispatch, root } = await interruptedRestart() await host.restartResume.list() - // Started ahead of the continuation, so a newer message can land from the provider. - await startAgent({ host, store }) - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - // The newer message lands after the reattach and just before the continuation is accepted, - // which is where a send asks whether its offer still stands. + const body = hostTestMessage('A newer task from another client') + const answer = () => + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) const send = host.send const writing = vi.spyOn(host, 'send') - writing.mockImplementationOnce(async (caller, params) => { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - await host.flushStreamedEvents(SESSION) - return send(caller, params) - }) + if (!userAnswers) { + writing.mockImplementationOnce(async (caller, params) => { + await answer() + return send(caller, params) + }) + } const admit = StructuredAgentSessionResumeAdmission.prototype.run const admitting = vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run') - const body = hostTestMessage('Carry on from where you stopped') - const answer = () => - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) if (userAnswers) { admitting.mockImplementationOnce(async function (this, ...args) { await (userAnswers === 'before' ? answer() : null) @@ -190,9 +181,7 @@ export async function supersededRefusal(userAnswers?: 'before' | 'after') { } try { const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.continued).toMatchObject([{ outcome: 'refused' }]) - expect(dispatch).toHaveBeenCalledTimes(userAnswers ? 1 : 0) - return { host, root, result } + return { host, store, acquire, dispatch, root, result } } finally { writing.mockRestore() admitting.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts index 2d6f31bc223d..032b0e07b2af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts @@ -1,116 +1,42 @@ -// An offer across the provider reattaching and rewriting the chat in its own words: a notice turn -// of its own, restated rows. None of it withdraws the offer or changes the message Resume sends. +// An offer across a failed attempt and its retry, and across builds: the message Resume sends +// depends on the marker alone, and each action sends its own. import { expect, it } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_RESTART_CONTINUATION_MESSAGE } from '../../../shared/agent-session-restart-continuation' -import { restartContinuationBody } from './structured-agent-session-restart-continuation' -import { - interruptedRestart, - startAgent -} from './structured-agent-session-restart-interruption-test-harness' +import { restartContinuationBody } from './structured-agent-session-restart-continuation-envelope' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD + HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' -function resumedEvents(state: Awaited>) { - // The latest acquisition: an earlier one may have been refused. - const events = state.acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - return events -} - -// THE REPORTED REFUSAL: opening the chat reattached Claude, which opened a turn of its own to say -// the previous session did not finish, and closed it. The chat is still offered and Resume sends — -// including when the offer is a send that had not become a turn, where that closed notice turn is -// the newest turn in the journal. -it.each(['turn', 'submission'] as const)( - 'still offers and continues a %s offer after the provider opens and closes a notice turn', - async (work) => { - const state = await interruptedRestart(work, false) - const { host, dispatch } = state - await startAgent(state) - const events = resumedEvents(state) - const notice = { provider: 'codex', threadId: THREAD, turnId: 'notice-turn' } as const - events.appendItem( - { ...notice, ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'running', userItemId: 'turn:notice-turn' } - ) - events.appendItem( - { ...notice, ordinal: 2 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: "didn't finish before the previous session ended" }] - } - ) - events.appendItem( - { ...notice, ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'completed', userItemId: 'turn:notice-turn' } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) - expect( - (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued - ).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - } -) - -// A retry after the first attempt never reached the provider, with the rows restated in between: -// the body depends on the marker alone, so the ledger fingerprint stays the offer's. -it('sends the same continuation body on a retry after the provider restates its rows', async () => { - const state = await interruptedRestart('children') - const { host, root, dispatch, marker } = state +// The first attempt's start failed, so its continuation was rejected and never reached the agent. +// A retry is a new action with a new message, not a replay of the rejected one, and it is +// delivered with the same body. +it("delivers a retry as a new continuation after the first one's start failed", async () => { + const { host, acquire, dispatch, marker } = await interruptedRestart('children') if (!marker) { throw new Error('missing interrupted restart marker') } - // An earlier action refused before any continuation was accepted. - const capsule = new AgentSessionRecoveryCapsule(root) - expect(await host.restartResume.list()).toHaveLength(1) - await capsule.beginResume([SESSION], 'earlier-action', NOW) - await capsule.failResume( - 'earlier-action', - [ - { - sessionId: SESSION, - failedAt: NOW, - outcome: 'refused', - reason: 'agent_session_conflict', - latestPrompt: '', - latestUserItemId: marker.latestUserItemId - } - ], - NOW - ) - expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: true }]) + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) + const [rejected] = (await host.journalSnapshot(SESSION)).submissions + expect(rejected).toMatchObject({ dispatchState: 'rejected' }) - await startAgent(state) - resumedEvents(state).appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') expect(retried.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - expect(dispatch.mock.calls[0]?.[0].body).toEqual(restartContinuationBody(marker)) + expect(dispatch).toHaveBeenCalledOnce() + const sent = dispatch.mock.calls[0]?.[0] + expect(sent?.clientMessageId).not.toBe(rejected?.clientMessageId) + expect(sent?.body).toEqual(restartContinuationBody(marker)) + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { clientMessageId: rejected?.clientMessageId, dispatchState: 'rejected' }, + { clientMessageId: sent?.clientMessageId, dispatchState: 'accepted' } + ]) + expect(retried.failed).toEqual([]) }) // A marker from a build that recorded only a working lead: no snapshot, so no activity to name, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index e00cc1c9a918..2973be593ef4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -1,5 +1,5 @@ -// The ways an offer ends. Every ending is the user's own act, and every ending DELETES the -// durable record — nothing stays behind to be re-filtered on every later read. +// The ways an offer ends. Every ending DELETES the durable record — nothing stays behind to be +// re-filtered on every later read. import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' @@ -13,8 +13,8 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' -// The user answering the chat themselves is the one signal the offer is moot. The next listing -// notices and deletes the durable record, not merely hides it. +// The user's own message starts the chat's agent again, which ends the offer: the durable record +// is deleted, not merely hidden (R-04). it('deletes the offer once the user sends their own message in that chat', async () => { const { host, root, dispatch } = await interruptedRestart() const capsule = new AgentSessionRecoveryCapsule(root) @@ -24,13 +24,13 @@ it('deletes the offer once the user sends their own message in that chat', async const body = hostTestMessage('Never mind, do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - expect(await host.restartResume.list()).toEqual([]) await vi.waitFor(async () => { expect(await capsule.list(NOW)).toEqual([]) }) + expect(await host.restartResume.list()).toEqual([]) }) -// A journal this host cannot read says nothing about the user moving on, so it must not end the offer. +// A journal this host cannot read decides nothing, so it must not end the offer. it('keeps the offer when the chat cannot be read on this host', async () => { const { host, root, store } = await interruptedRestart('submission') const capsule = new AgentSessionRecoveryCapsule(root) 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 new file mode 100644 index 000000000000..604155bc4989 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -0,0 +1,283 @@ +// A restart offer ends when the chat's agent is started again, other than by its own continuation, +// and a message and a continuation racing to be first are decided at acceptance. Each case reads +// the offer list and the capsule, never only an in-memory set. + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + AgentSessionRecoveryCapsule, + AGENT_SESSION_RECOVERY_CAPSULE_FILE +} from '../../runtime/agent-session-recovery-capsule' +import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + interruptedRestart, + statusNotes +} from './structured-agent-session-restart-interruption-test-harness' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +afterEach(() => vi.restoreAllMocks()) + +const SUPERSEDED = 'agent_session_restart_work_superseded' + +async function offered(work: 'turn' | 'submission' = 'turn') { + const state = await interruptedRestart(work, false) + expect(await state.host.restartResume.list()).toHaveLength(1) + return state +} + +function offersIn(root: string) { + return new AgentSessionRecoveryCapsule(root).list(NOW) +} + +function userSend(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function sentTexts(dispatch: Awaited>['dispatch']): string[] { + return dispatch.mock.calls.map(([input]) => + input.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('') + ) +} + +it('withdraws the offer when /compact starts the agent at rest (R-01)', async () => { + const { host, root, acquire } = await offered() + Object.assign(host.deps.adapter, { compact: vi.fn(async () => ({})) }) + + expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) + + expect(acquire).toHaveBeenCalledOnce() + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) + expect(await host.restartResume.list()).toEqual([]) +}) + +it('keeps the offer through every way of looking at the chat (R-02)', async () => { + const { host, root, acquire } = await offered() + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + await host.readOptions(SESSION) + host.readCommands(SESSION) + await host.handoffStatus(SESSION) + await host.revealSession(SESSION) + unsubscribe() + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.list()).toHaveLength(1) + expect(await offersIn(root)).toHaveLength(1) +}) + +it("does not let the offer's own continuation withdraw it (R-03)", async () => { + const { host, root, dispatch } = await offered() + const dismiss = vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'dismiss') + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(dispatch).toHaveBeenCalledOnce() + expect(dismiss).not.toHaveBeenCalled() + // Ended by the success path instead. + expect(await offersIn(root)).toEqual([]) +}) + +it('refuses the continuation when another start lands inside its action (R-03b)', async () => { + const { host, root, dispatch } = await offered() + Object.assign(host.deps.adapter, { compact: vi.fn(async () => ({})) }) + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + // Another client's /compact starts the agent after the action reserved the offer. + expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) + return send(caller, params) + }) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) + expect(dispatch).not.toHaveBeenCalled() + expect(await offersIn(root)).toEqual([]) + expect(result.failed).toEqual([]) +}) + +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]) + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.list()).toHaveLength(1) + expect(await offersIn(root)).toHaveLength(1) +}) + +it('reads an older build’s marker whose message id no longer matches, and one with none (R-06)', async () => { + const { host, root, dispatch, marker } = await offered('submission') + if (!marker) { + throw new Error('missing interrupted restart marker') + } + // The older build compared this id with the chat's newest message; nothing does now. + await host.restartResume.dismiss([SESSION]) + await new AgentSessionRecoveryCapsule(root).record( + [{ ...marker, latestUserItemId: 'user-sent-under-the-older-build' }], + NOW + ) + expect(await host.restartResume.list()).toHaveLength(1) + const { latestUserItemId: _dropped, ...withoutId } = marker + expect(parseAgentSessionResumeMarker(withoutId)).toEqual(withoutId) + + await host.send(CALLER, userSend('Carry on')) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) +}) + +it('still writes the message id the previous build requires on every marker', async () => { + const { root } = await offered('submission') + const capsule = JSON.parse( + await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') + ) + expect(capsule.entries[0]?.marker).toHaveProperty('latestUserItemId') +}) + +it('keeps the offer when the send that would start the agent fails to (R-08)', async () => { + const { host, root, acquire } = await offered() + acquire.mockRejectedValueOnce(new Error('Not signed in')) + const params = userSend('while signed out') + + await host.send(CALLER, params) + + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(await host.restartResume.list()).toHaveLength(1) + expect(await offersIn(root)).toHaveLength(1) +}) + +it('keeps the offer a quit captures when a start lands while quitting (R-09)', async () => { + const { host, root, acquire } = await offered() + const starting = Promise.withResolvers() + const acquired = Promise.withResolvers() + const spawn = acquire.getMockImplementation()! + acquire.mockImplementationOnce(async (input) => { + acquired.resolve() + await starting.promise + return spawn(input) + }) + await host.send(CALLER, userSend('started as the app quits')) + await acquired.promise + + const quitting = host.flushAllStreamedEvents({ trigger: 'quit' }) + starting.resolve() + await quitting + + expect(await offersIn(root)).toHaveLength(1) +}) + +// Resume that runs by itself at launch goes through the same call a click does, and the same rule +// decides: whichever of the two was accepted first since the restart wins. +it("refuses an automatic continuation when the user's message was accepted first, and writes nothing", async () => { + const { host, root, dispatch } = await offered() + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = await host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => events.push(structuredClone(event)) + }) + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + // Both accepted before any start, the user's first: the continuation queues behind the user's + // acceptance, ahead of the start that acceptance wakes. + let continuation: ReturnType | undefined + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementationOnce( + async function (this: AgentSessionJournal, ...args) { + continuation = send(caller, params) + await new Promise((resolve) => setTimeout(resolve, 50)) + return append.apply(this, args) + } + ) + await send(CALLER, userSend('A new request')) + return continuation! + }) + + const result = await host.restartResume.continueAfterRestart(undefined, 'launch') + + expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual(['A new request']) + // Nothing the user did failed: no row reached the reader, and nothing is kept. + expect(await statusNotes(host)).toEqual([]) + expect( + events.some( + (event) => + event.type === 'batch' && event.batch.items.some((item) => item.body.kind === 'status') + ) + ).toBe(false) + expect(await offersIn(root)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) + unsubscribe() +}) + +it('delivers a clicked continuation and a message sent right after it, in the order accepted', async () => { + const { host, root, dispatch } = await offered() + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + const continuation = send(caller, params) + const user = send(CALLER, userSend('And then this')) + await user + return continuation + }) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) + const [first, second] = sentTexts(dispatch) + expect(first).not.toBe('And then this') + expect(second).toBe('And then this') + expect(await offersIn(root)).toEqual([]) +}) + +// The user's message came first but its start failed, so no start withdrew the offer: Resume is +// still refused quietly, and nothing is filed for the user to act on. +it("files nothing when Resume loses to the user's message whose start failed", async () => { + const { host, root, acquire, dispatch } = await offered() + acquire.mockRejectedValueOnce(new Error('Not signed in')) + const params = userSend('while signed out') + await host.send(CALLER, params) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(await host.restartResume.list()).toHaveLength(1) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result).toMatchObject({ + continued: [{ outcome: 'refused', reason: SUPERSEDED }], + sessions: [], + failed: [] + }) + expect(dispatch).not.toHaveBeenCalled() + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) + expect(await offersIn(root)).toEqual([]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts new file mode 100644 index 000000000000..b3983c7faf27 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -0,0 +1,89 @@ +// When a restart offer ends without being acted on: the chat's agent was started again. +// +// The fact is the one the agent-status store already carries — a chat is host-owned exactly while +// its agent runs — read at its not-owned → owned edge. Restored and replayed rows carry no running +// agent, and opening a chat starts none, so only a real start withdraws. A resume action's own +// start does not; any other start during that action withdraws the offer and refuses the action. +// Two acts racing to be first — a message and a continuation — are decided at acceptance instead: +// the first one accepted since the restart wins. + +import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< + typeof createStructuredAgentSessionRestartOfferWithdrawal +> + +export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { + sessions: ReadonlyMap + capsule?: Pick + /** Quit has begun: a start that lands now must not touch the offers teardown is capturing. */ + isDisposed: () => boolean + /** Whether this message was a restart continuation, by who sent it. */ + isContinuation: (clientMessageId: string) => boolean + now: () => number + /** The capsule's single mutation lane, shared with the offer's own operations. */ + enqueue: (operation: () => Promise) => Promise +}) { + const actions = new Map() + + /** The offer and any failure record go. Advisory: a failed write is logged, never raised. */ + const retire = (sessionId: string): void => { + const capsule = deps.capsule + if (!capsule) { + return + } + void deps + .enqueue(() => capsule.dismiss([sessionId], deps.now())) + .catch(() => { + console.warn('[structured-agent-session] withdrawing a restart offer failed') + }) + } + + return { + /** A resume action holds the chat from its reservation until it settles. */ + begin: (sessionId: string, continuationId: string): (() => void) => { + const action = { continuationId, withdrawn: false } + actions.set(sessionId, action) + return () => { + if (actions.get(sessionId) === action) { + actions.delete(sessionId) + } + } + }, + /** Another start withdrew the offer while an action held it. */ + withdrawn: (sessionId: string): boolean => actions.get(sessionId)?.withdrawn === true, + /** A message was accepted since the restart: by this conversation's open handle, which the + * restart closed. A continuation that was rejected never reached the agent, so it does not + * count: a retry sends a new one. */ + acceptedSinceRestart: (sessionId: string): boolean => { + const journal = deps.sessions.get(sessionId)?.journal + return (journal?.submissions() ?? []).some( + (submission) => + !journal?.wroteBeforeOpen(submission.acceptedSequence) && + !( + submission.dispatchState === 'rejected' && + deps.isContinuation(submission.clientMessageId) + ) + ) + }, + onOwnedEdge: (sessionId: string): void => { + if (deps.isDisposed()) { + return + } + const action = actions.get(sessionId) + if (action) { + // The action's own start is the one whose oldest undelivered message is its continuation. + const oldest = deps.sessions + .get(sessionId) + ?.journal.submissions() + .find((submission) => submission.dispatchState === 'pending') + if (oldest?.clientMessageId === action.continuationId) { + return + } + action.withdrawn = true + } + retire(sessionId) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 9c163c0378fc..f24957e3d98c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -7,8 +7,6 @@ import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-res import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { pendingApproval } from './structured-agent-session-restart-resume-test-harness' -import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -91,29 +89,8 @@ it.each(['turn', 'submission'] as const)( } ) -it('preserves the offer when the original submission receives its provider echo', async () => { - const { host, acquire, dispatch } = await interruptedRestart('submission', false) - expect(await host.restartResume.list()).toHaveLength(1) - await startAgent(hostTestState()) - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('Perform the original task') - ) - await host.flushStreamedEvents(SESSION) - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - expect( - (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued - ).toMatchObject([{ outcome: 'continued' }]) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).toHaveBeenCalledTimes(1) -}) - -// Teardown judged the chat working, and only the user moving on withdraws that. A send the -// provider proves it never received is still the chat Orca stopped; the continuation asks the agent +// Teardown judged the chat working. A send the provider proves it never received is still the chat +// Orca stopped; the continuation asks the agent // to check what finished rather than refusing. it('still continues a chat whose last send acquisition proves was never delivered', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission') @@ -128,215 +105,49 @@ it('still continues a chat whose last send acquisition proves was never delivere expect(result.continued).toMatchObject([{ outcome: 'continued' }]) }) -/** Holds the continuation at send admission while `during` runs, then lets it proceed. */ -async function continueAcross( - state: Awaited>, - during: (events: NonNullable>) => void -) { - const { host, store } = state - expect(await host.restartResume.list()).toHaveLength(1) - await startAgent(hostTestState()) - const events = resumedEvents(state) - if (!events) { - throw new Error('missing resumed provider event sink') - } - const admitting = Promise.withResolvers() - const proceed = Promise.withResolvers() - const admit = store.admitMutationOperation - vi.spyOn(store, 'admitMutationOperation').mockImplementationOnce(async (input) => { - admitting.resolve() - await proceed.promise - return admit(input) - }) - const continuing = host.restartResume.continueAfterRestart([SESSION], 'modal') - await admitting.promise - during(events) - await host.flushStreamedEvents(SESSION) - proceed.resolve() - return continuing -} - -function resumedEvents(state: Awaited>) { - return state.acquire.mock.calls[0]?.[0].events -} - -it('refuses at send admission once the user has sent a newer message', async () => { - const state = await interruptedRestart() - const { host, dispatch } = state - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - ) - expect(result.resumed).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - expect(dispatch).not.toHaveBeenCalled() - // Refused where it would have been accepted, so the chat records no continuation at all. - expect((await host.journalSnapshot(SESSION)).submissions).toEqual([]) -}) - -// Claude opens a turn of its own on reattach to say the last session did not finish. Resume is -// pressed while that turn still runs: the continuation is sent and the provider queues it. -it('sends the continuation while a turn the provider opened is still running', async () => { - const state = await interruptedRestart() - const { dispatch } = state - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'notice-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'running' } - ) - ) - expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) -}) - -// The send the user made just before quitting, after an earlier exchange had finished. Neither -// that finished turn nor the provider completing the send's own turn after the restart withdraws it. +// The send the user made just before quitting, after an earlier exchange had finished: that +// finished turn does not withdraw it. it('offers and continues a send made after an earlier completed turn', async () => { - const state = await interruptedRestart('send-after-reply', false) - const { dispatch, marker } = state + const { host, dispatch, marker } = await interruptedRestart('send-after-reply', false) expect(marker?.work.kind).toBe('submission') - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'original-turn', state: 'completed' } - ) - ) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) }) -it.each([false, true])( - 'refuses a queued newer message before dispatch even if later settlement fails: %s', - async (settlementFails) => { - const { host, store, acquire, dispatch } = await interruptedRestart('submission', false) - expect(await host.restartResume.list()).toHaveLength(1) - await startAgent(hostTestState()) - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const settle = store.recordOperationOutcome.bind(store) - let admitted = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (admitted) { - if (settlementFails) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - } - await settle(input) - admitted = true - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, - { kind: 'status', text: 'Provider finished its last action' } - ) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client'), - { lifecycle: true } - ) - // Journaled before the continuation's acceptance asks whether its offer still stands. - await host.flushStreamedEvents(SESSION) - }) - - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect(result.continued).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - expect(dispatch).not.toHaveBeenCalled() - // Refused before acceptance: only the interrupted send is in the journal. - expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) - // The offer is spent, but the refusal is kept as a durable failure: a retry finds it, and the - // superseded chat is still not eligible, so nothing runs and the record stays for the user. - expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toMatchObject({ - resumed: [], - continued: [], - sessions: [], - failed: [ - { sessionId: SESSION, outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ] - }) - expect(dispatch).not.toHaveBeenCalled() - if (settlementFails) { - expect(store.recordOperationOutcome).toHaveBeenCalledOnce() - expect(warning).not.toHaveBeenCalled() - } - warning.mockRestore() - } -) - -// What the provider says after reattaching does not re-judge the offer. -it.each(['completed', 'approval', 'question'] as const)( - 'continues past provider %s evidence accepted while recording the continuation', - async (event) => { - const { host, acquire, dispatch } = await interruptedRestart() - await host.restartResume.list() - await startAgent(hostTestState()) - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const append = AgentSessionJournal.prototype.appendSubmission - const writing = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') - writing.mockImplementationOnce(async function (this: AgentSessionJournal, input) { - const cursor = await append.call(this, input) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - event === 'completed' - ? { kind: 'turn', turnId: 'interrupted-turn', state: 'completed' } - : { ...pendingApproval().body, question: 'Which action?', kind: event }, - { lifecycle: true } - ) - return cursor - }) - try { - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - } finally { - writing.mockRestore() - } - } -) - it('replays the same logical continuation through the durable send ledger', async () => { - const { host, store, dispatch, marker } = await interruptedRestart() - if (!marker) { - throw new Error('missing interrupted restart marker') - } + const { host, dispatch } = await interruptedRestart() + const sending = vi.spyOn(host, 'send') await host.restartResume.continueAfterRestart([SESSION], 'modal') - const fence = store.getRecord(SESSION)?.lease.runtimeFence - if (fence === undefined) { - throw new Error('missing resumed lease') + const sent = sending.mock.calls[0]?.[1] + sending.mockRestore() + if (!sent) { + throw new Error('the continuation was not sent') } const replay = await host.send( { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, - restartContinuationEnvelope(SESSION, fence, marker) + { envelope: sent.envelope, body: sent.body } ) expect(replay).toMatchObject({ ok: true, replayed: true }) expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) expect(dispatch).toHaveBeenCalledTimes(1) }) +// A send that throws after Orca may have taken it is not proof it was not delivered. it.each([false, true])( - 'keeps dispatched delivery unconfirmed when settlement fails (uncertainty write fails: %s)', + 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)', async (uncertaintyFails) => { - const { host, store, dispatch } = await interruptedRestart() + const { host, store } = await interruptedRestart() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(await host.restartResume.list()).toHaveLength(1) - await startAgent(hostTestState()) const settle = store.recordOperationOutcome.bind(store) - let dispatched = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { - dispatched = true - } - if (dispatched && (input.outcome.status === 'succeeded' || uncertaintyFails)) { + const recording = vi.spyOn(store, 'recordOperationOutcome') + recording.mockImplementation(async (input) => { + // Only the continuation's own record: the start it makes records its attach as usual. + if ( + input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && + (input.outcome.status === 'succeeded' || uncertaintyFails) + ) { throw new Error('operation outcome could not be persisted') } return settle(input) @@ -344,35 +155,32 @@ it.each([false, true])( const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(dispatch).toHaveBeenCalledTimes(1) - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) - // Filed as unconfirmed, with a warning in the chat; the continuation's own message is part of - // the filed state, so it does not retire the record it caused. + // Filed as unconfirmed, with a warning in the chat. expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - await host.restartResume.continueAfterRestart([SESSION], 'retry') - expect(dispatch).toHaveBeenCalledTimes(1) - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - expect(warning.mock.calls.flat()).not.toContainEqual( - expect.objectContaining({ message: 'operation outcome could not be persisted' }) - ) + recording.mockRestore() warning.mockRestore() } ) // The continuation is accepted, then its start fails: the message is rejected with the cause and // the failure is filed, and nothing is stopped because nothing started. -it('rejects the continuation when its start fails, and files the refusal', async () => { +it('rejects the continuation when its start fails, and files a retryable refusal', async () => { const { host, acquire, dispatch, closeSession } = await interruptedRestart() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.resumed).toMatchObject([{ outcome: 'refused' }]) expect(result.continued).toMatchObject([{ outcome: 'refused' }]) - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused' }]) + // Nothing ran, so the offer stands as a failure a retry can act on. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ { dispatchState: 'rejected' } ]) @@ -381,32 +189,25 @@ it('rejects the continuation when its start fails, and files the refusal', async expect(closeSession).not.toHaveBeenCalled() }) -it.each([false, true])( - 'admits one durable continuation under concurrent calls (pane already live: %s)', - async (alreadyLive) => { - const { host, root, acquire, dispatch } = await interruptedRestart() - if (alreadyLive) { - expect(await host.restartResume.list()).toHaveLength(1) - await startAgent(hostTestState()) - } - const results = await Promise.all([ - host.restartResume.continueAfterRestart([SESSION], 'window-one'), - host.restartResume.continueAfterRestart([SESSION], 'window-two') - ]) - expect( - results.flatMap((result) => result.resumed).filter((r) => r.outcome === 'resumed') - ).toHaveLength(1) - expect( - results.flatMap((result) => result.continued).filter((r) => r.outcome === 'continued') - ).toHaveLength(1) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).toHaveBeenCalledTimes(1) - expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) - expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) - await host.restartResume.continueAfterRestart([SESSION], 'later-click') - expect(dispatch).toHaveBeenCalledTimes(1) - } -) +it('admits one durable continuation under concurrent calls', async () => { + const { host, root, acquire, dispatch } = await interruptedRestart() + const results = await Promise.all([ + host.restartResume.continueAfterRestart([SESSION], 'window-one'), + host.restartResume.continueAfterRestart([SESSION], 'window-two') + ]) + expect( + results.flatMap((result) => result.resumed).filter((r) => r.outcome === 'resumed') + ).toHaveLength(1) + expect( + results.flatMap((result) => result.continued).filter((r) => r.outcome === 'continued') + ).toHaveLength(1) + expect(acquire).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) + await host.restartResume.continueAfterRestart([SESSION], 'later-click') + expect(dispatch).toHaveBeenCalledTimes(1) +}) // The batch counts a chat done once its agent took the continuation; the provider's slow answer is // awaited after, and decides the outcome. @@ -523,71 +324,52 @@ it('fails closed on corrupt recovery storage while an ordinary send still works' await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - // list; the action's read of offers and of failures; the post-action refresh of both. - expect(warning).toHaveBeenCalledTimes(5) + // list; the action's read of offers and of failures; the post-action refresh of both; and the + // send's start, which cannot withdraw an offer it cannot read. + expect(warning).toHaveBeenCalledTimes(6) + expect(warning).toHaveBeenLastCalledWith( + '[structured-agent-session] withdrawing a restart offer failed' + ) warning.mockRestore() }) -// The toast is gone in seconds and the offer is spent by the reattach, so without this record -// nothing on any surface would still name the chat the user has to continue by hand. -it('keeps a refused continuation as a durable failure that names the chat and the reason', async () => { - const { host, root, result } = await supersededRefusal() - const failure = { - sessionId: SESSION, - outcome: 'refused', - reason: 'agent_session_restart_work_superseded', - latestPrompt: expect.any(String), - agent: 'codex', - retryable: false - } - expect(result).toMatchObject({ sessions: [], failed: [failure] }) - // The chat itself says what happened and what to do. - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' +// The user's own message was accepted first: the continuation is refused, and since nothing the +// user did failed, the chat says nothing and no failure is kept. +it("refuses a continuation quietly when the user's own message was accepted first", async () => { + const { host, root, dispatch, result } = await supersededRefusal() + expect(result).toMatchObject({ + continued: [{ outcome: 'refused', reason: 'agent_session_restart_work_superseded' }], + sessions: [], + failed: [] }) - expect(await host.restartResume.list()).toEqual([]) - expect(await host.restartResume.listFailures()).toMatchObject([failure]) - // Durable: a fresh reader of the same file sees it too. - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toMatchObject([ - { marker: { sessionId: SESSION }, outcome: 'refused' } - ]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(await statusNotes(host)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) -// The failure asked the user to continue the chat themselves; their own message is that -// continuation. Nothing on the send path clears it: the listing sees the newer message. +/** A continuation whose start failed, filed as a retryable failure. */ +async function failedContinuation() { + const state = await interruptedRestart() + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const result = await state.host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused' }]) + return state +} + +// Starting the chat's agent again answers a failure, as it ends an offer. it('retires a recorded failure once the user sends in that chat, with no send hook', async () => { - const { host, root } = await supersededRefusal() + const { host, root, dispatch } = await failedContinuation() const body = hostTestMessage('Carry on from where you stopped') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - expect(await host.restartResume.listFailures()).toEqual([]) - // Pruned from the file too, not only hidden. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + // Pruned from the file, not only hidden. await vi.waitFor(async () => { expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) + expect(await host.restartResume.listFailures()).toEqual([]) }) -// The user can reply in a chat while other chats in the same action are still being continued, -// before its turn in the batch or after its own note asks them to. Either reply answers the failure. -it.each(['before', 'after'] as const)( - 'retires a failure the user answered %s its own attempt, before the action settled', - async (userAnswers) => { - const { host, root, result } = await supersededRefusal(userAnswers) - expect(result.resumed).toMatchObject([ - userAnswers === 'before' ? { reason: 'agent_session_resume_not_eligible' } : {} - ]) - expect( - (await statusNotes(host)).some( - (note) => note.text === AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE - ) - ).toBe(userAnswers === 'after') - expect(result.failed).toEqual([]) - await vi.waitFor(async () => { - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) - }) - } -) - it('removes a failure when a named retry succeeds', async () => { const { host, root, dispatch } = await interruptedRestart() const capsule = new AgentSessionRecoveryCapsule(root) @@ -603,7 +385,7 @@ it('removes a failure when a named retry succeeds', async () => { outcome: 'refused', reason: 'agent_session_conflict', latestPrompt: '', - latestUserItemId: pending!.latestUserItemId + latestUserItemId: pending!.latestUserItemId ?? null } ], NOW @@ -619,7 +401,7 @@ it('removes a failure when a named retry succeeds', async () => { }) it('dismisses one failure by name and leaves the rest of the durable records alone', async () => { - const { host, root } = await supersededRefusal() + const { host, root } = await failedContinuation() const capsule = new AgentSessionRecoveryCapsule(root) const other = parseAgentSessionResumeMarker({ sessionId: 'session-other', 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 0f01639ee281..6e965f7ac9ac 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 @@ -1,6 +1,6 @@ // Restart offers are durable per-session records. Listing reserves nothing and removes only offers // the chat has provably moved past; an explicit action reserves records, and only a completed -// action removes them — or files what went wrong. +// action removes them — or files what went wrong. Starting the chat's agent again withdraws them. import { randomUUID } from 'node:crypto' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -9,12 +9,6 @@ import type { AgentSessionResumeMarker, AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionSendResult -} from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' @@ -39,30 +33,17 @@ import { type StructuredAgentSessionContinuationHost, type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' +import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { + STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, + type StructuredAgentSessionRestartResumeSurfaces +} from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' type LiveSession = { journal: AgentSessionJournal; child: { fence: number } | null } -export type StructuredAgentSessionRestartResumeSurfaces = { - revealSession: (sessionId: string) => Promise<{ readable: boolean }> - send: (input: { - envelope: AgentSessionMutationEnvelope - body: AgentJournalMessageItem - beforeRun?: () => void - }) => Promise> - awaitSendSettlement: ( - sessionId: string, - clientMessageId: string - ) => Promise<{ value: AgentSessionSendResult } | undefined> - awaitSendHandedOver: ( - sessionId: string, - clientMessageId: string - ) => Promise<{ value: AgentSessionSendResult } | undefined> - onNoteFailed: (sessionId: string, error: unknown) => void - now: () => number -} - export type StructuredAgentSessionRestartResume = { /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation * once the stop is proven, then one write of the confirmed offers. */ @@ -84,6 +65,8 @@ export type StructuredAgentSessionRestartResume = { }> /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ dismiss: (sessionIds?: readonly string[]) => Promise + /** The chat's agent was started: its offer ends unless the start is a resume's own. */ + onOwnedEdge: (sessionId: string) => void } export function createStructuredAgentSessionRestartResume( @@ -112,12 +95,20 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions, + ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), + isDisposed: surfaces.isDisposed, + isContinuation: (clientMessageId) => + deps.store.getOperationRow( + STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, + clientMessageId + ) !== null, + now: surfaces.now, + enqueue: enqueueRecoveryOperation + }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), - sessions, - reveal: async (sessionId) => { - await surfaces.revealSession(sessionId).catch(() => null) - }, getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, @@ -154,8 +145,10 @@ export function createStructuredAgentSessionRestartResume( deps.store.getRecord(sessionId) ? structuredAgentSessionConversationFence(deps.store, sessionId) : null, - stillResumable: (marker, options) => - derive([marker], 'may-be-held', options).candidates.length === 1 + stillResumable: (marker) => + derive([marker], 'may-be-held').candidates.length === 1 && + !withdrawal.withdrawn(marker.sessionId) && + !withdrawal.acceptedSinceRestart(marker.sessionId) } /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at @@ -163,7 +156,7 @@ export function createStructuredAgentSessionRestartResume( const run = async ( sessionIds: readonly string[] | undefined, owner: string, - continueOne: (marker: AgentSessionResumeMarker) => Promise + continueOne: (marker: AgentSessionResumeMarker, operationId: string) => Promise ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. @@ -189,31 +182,39 @@ export function createStructuredAgentSessionRestartResume( )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates - const attempts = failures.attempts(markersBySession) + const releases = reserved.map((marker) => + withdrawal.begin( + marker.sessionId, + restartContinuationId(marker.sessionId, marker, operationId) + ) + ) try { const outcomes = await resumeStructuredAgentSessionsFromRestart( { admission, consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) - return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 + return ( + marker !== undefined && + derive([marker], 'may-be-held').candidates.length === 1 && + !withdrawal.withdrawn(sessionId) + ) }, resume: async (sessionId) => { const marker = markersBySession.get(sessionId) - try { - if (marker) { - await continueOne(marker) - } - } finally { - attempts.observe(sessionId) + if (marker) { + await continueOne(marker, operationId) } } }, candidates, owner ) - return { operationId, outcomes, candidates, attempts } + return { operationId, outcomes, candidates, markers: markersBySession, releases } } catch (error) { + for (const release of releases) { + release() + } if (deps.recoveryCapsule) { await enqueueRecoveryOperation(() => deps.recoveryCapsule!.rollbackResume(operationId, surfaces.now()) @@ -238,11 +239,12 @@ export function createStructuredAgentSessionRestartResume( const verdicts: Promise[] = [] // A chat holds its slot until its agent took the continuation or its start failed, so a batch // never starts more agents at once than the runner allows; the provider's answer comes after. - const action = await run(sessionIds, owner, async (marker) => { + const action = await run(sessionIds, owner, async (marker, operationId) => { const started = await startStructuredAgentSessionContinuation( restartContinuationDeps(continuationHost, marker), marker.sessionId, - marker + marker, + operationId ) if ('done' in started) { continued.push(started.done) @@ -256,9 +258,12 @@ export function createStructuredAgentSessionRestartResume( await Promise.all(verdicts) const resumed = action?.outcomes ?? [] if (action) { + for (const release of action.releases) { + release() + } await failures.settle(action.operationId, resumed, { candidates: action.candidates, - attempts: action.attempts, + markers: action.markers, failureAfterResume: (sessionId) => { const outcome = continued.find((entry) => entry.sessionId === sessionId) return outcome ? continuationFailureOutcome(outcome.outcome) : null @@ -307,6 +312,7 @@ export function createStructuredAgentSessionRestartResume( // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), - continueAfterRestart + continueAfterRestart, + onOwnedEdge: withdrawal.onOwnedEdge } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 245fbcf4bbf1..8b6b0ca1c7f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -7,11 +7,11 @@ // restored thread), and every reading of those rewrites as "the work is done" dropped chats that // were owed a resume. // -// An offer ends only by the user's own actions. The ones this predicate can see — a newer message -// of theirs in that chat, or the conversation forked — are reported back as `superseded` so the -// caller DELETES the record rather than filtering it forever. What remains are structural checks -// that are not about work at all: the record still exists and this build supports it, and the -// lease is free. +// An offer ends when the chat's agent is started again, other than by its own continuation; the +// host retires it at that start. The one ending this predicate can see — the conversation forked — +// is reported back as `superseded` so the caller DELETES the record rather than filtering it +// forever. What remains are structural checks that are not about work at all: the record still +// exists and this build supports it, and the lease is free. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -65,8 +65,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa export type StructuredAgentSessionResumableSet = { candidates: StructuredAgentSessionResumeCandidate[] - /** Markers the chat has provably moved past — a newer user message, or a forked conversation. - * Every ending deletes: the caller retires these rather than re-filtering them forever. */ + /** Markers the chat has provably moved past: a forked conversation. Every ending deletes: the + * caller retires these rather than re-filtering them forever. */ superseded: AgentSessionResumeMarker[] } @@ -75,8 +75,6 @@ export type StructuredAgentSessionResumeSetInput = { getRecord: (sessionId: string) => AgentSessionRecord | null supportsRecord: (record: AgentSessionRecord) => boolean latestPrompt: (sessionId: string) => string - /** Undefined when the chat's journal is not readable here, which decides nothing. */ - latestUserItemId: (sessionId: string) => string | null | undefined /** * Whether the lease must be free. * @@ -103,7 +101,7 @@ export function structuredAgentSessionResumableSet( continue } // A conversation that FORKED since teardown is not the one we marked, and can never be again: - // deleted like a newer message, so it cannot sit unseen forever. Compared by identity root, + // deleted, so it cannot sit unseen forever. Compared by identity root, // because a resume legitimately advances Claude's leaf and that is not a fork. const head = agentSessionProviderHandleChainHead(record.providerHandleChain) if (!head) { @@ -113,13 +111,6 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } - // The user moving on is the one thing that withdraws the offer. Anything the provider does on - // its own after reattaching — a turn it opens, a prompt, restated rows — is not. - const latestUserItemId = input.latestUserItemId(marker.sessionId) - if (latestUserItemId !== undefined && latestUserItemId !== marker.latestUserItemId) { - superseded.push(marker) - continue - } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) candidates.push({ sessionId: marker.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 68823c078dcd..38cb17ba42fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -203,13 +203,11 @@ export function resumableSet(input: { markers: AgentSessionResumeMarker[] items?: AgentJournalRenderItem[] chain?: AgentSessionRecord['providerHandleChain'] - latestUserItemId?: string | null }) { return structuredAgentSessionResumableSet({ markers: input.markers, getRecord: () => record(input.chain === undefined ? {} : { chain: input.chain }), supportsRecord: () => true, - latestPrompt: () => 'fix the auth bug', - latestUserItemId: () => input.latestUserItemId ?? null + latestPrompt: () => 'fix the auth bug' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index ce244fefe51f..65235738d11b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -12,9 +12,29 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' -import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-host' import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' +export type StructuredAgentSessionRestartResumeSurfaces = { + revealSession: (sessionId: string) => Promise<{ readable: boolean }> + send: (input: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + beforeRun?: () => void + }) => Promise> + awaitSendSettlement: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> + awaitSendHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> + onNoteFailed: (sessionId: string, error: unknown) => void + now: () => number + /** Quit has begun; see the offer withdrawal. */ + isDisposed: () => boolean +} + /** The caller key the continuation sends under, so its writes are attributable to Orca itself. */ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = 'trusted-local:restart-continuation' @@ -42,7 +62,7 @@ type RestartResumeHostBindings = { export function structuredAgentSessionRestartResumeSurfaces( host: RestartResumeHostBindings, now: () => number -): StructuredAgentSessionRestartResumeSurfaces { +): Omit { return { revealSession: host.revealSession, send: (params) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 09a172f0b1d5..51dd4602ea83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -389,8 +389,7 @@ describe('the resumable set', () => { markers: [marker({ providerHandleRoot: CLAUDE_ROOT })], getRecord: () => claudeRecord('5aed93d6-advanced-leaf'), supportsRecord: () => true, - latestPrompt: () => '', - latestUserItemId: () => null + latestPrompt: () => '' }) expect(candidates).toHaveLength(1) @@ -404,15 +403,14 @@ describe('the resumable set', () => { markers: [forked], getRecord: () => claudeRecord(null, 'prov-session-2'), supportsRecord: () => true, - latestPrompt: () => '', - latestUserItemId: () => null + latestPrompt: () => '' }) expect(set.candidates).toEqual([]) expect(set.superseded).toEqual([forked]) }) - // An offer has no expiry: it ends only by the user's own actions, however old it is. + // An offer has no expiry, however old it is. it('still offers a months-old marker', () => { const monthsAgo = NOW - 90 * 24 * 60 * 60 * 1000 expect(resumableSet({ markers: [marker({ recordedAt: monthsAgo })] }).candidates).toHaveLength( @@ -420,16 +418,6 @@ describe('the resumable set', () => { ) }) - // The user moving on is what withdraws an offer — and it is reported for DELETION, not merely - // filtered, so the record does not have to be re-filtered on every read forever. - it('withdraws and reports for deletion once the user has sent a newer message', () => { - const withdrawn = marker() - const set = resumableSet({ markers: [withdrawn], latestUserItemId: 'user-newer' }) - - expect(set.candidates).toEqual([]) - expect(set.superseded).toEqual([withdrawn]) - }) - // Structural refusals are NOT endings: a record this host cannot see right now must not delete // a durable offer the user still owns. it('does not report a structurally refused marker for deletion', () => { 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 5403ec16abe3..5e026d915a59 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 @@ -57,6 +57,8 @@ export type StructuredAgentSessionStatusFeedDeps = { /** Live provider-owned background tasks for the summary, so session lists can * render subagent children. Optional: a provider without the hook projects none. */ readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** The session's agent was started: its row went from not host-owned to host-owned. */ + onOwnedEdge?: (sessionId: string) => void } function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { @@ -114,6 +116,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } + onOwnedEdge?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ sessions: args.sessions, @@ -123,7 +126,8 @@ export function createStructuredAgentSessionHostStatusFeed(args: { readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId), // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. - statusSink: () => args.deps().statusSink + statusSink: () => args.deps().statusSink, + ...(args.onOwnedEdge ? { onOwnedEdge: args.onOwnedEdge } : {}) }) } @@ -220,6 +224,9 @@ export class StructuredAgentSessionStatusFeed { this.published.set(sessionId, summary) this.sink(summary, session.params.location) this.broadcast({ type: 'status', session: summary }) + if (summary.hostExecutionOwned && !previous?.hostExecutionOwned) { + this.deps.onOwnedEdge?.(sessionId) + } try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts index 2c308ec998ec..14b74b3e1fc2 100644 --- a/src/shared/agent-session-resume-marker.ts +++ b/src/shared/agent-session-resume-marker.ts @@ -6,8 +6,9 @@ // the stop, because that fact exists only in memory at that moment — the provider rewrites the // journal in its own words on reattach. // -// A marker has no expiry. It ends only by the user's own actions: a newer message in that chat, a -// successful resume, a dismissal, or closing the chat — each of which deletes it. +// A marker has no expiry. It ends when the chat's agent is started again other than by its own +// continuation, or by a successful resume, a dismissal, or closing the chat — each of which +// deletes it. import { z } from 'zod' import { AGENT_STATUS_STATES } from './agent-status-types' @@ -48,8 +49,9 @@ export type AgentSessionResumeMarker = { /** The work in flight when teardown observed it — a running turn, or a send that had not yet * become one. */ work: AgentSessionResumeWork - /** The user message observed at teardown; a newer one supersedes this offer before its turn opens. */ - latestUserItemId: string | null + /** The user message observed at teardown. Nothing reads it; still written for one release so the + * previous build, whose parser requires it, can read this marker after a downgrade. */ + latestUserItemId?: string | null /** Execution host's clock at teardown. */ recordedAt: number trigger: AgentSessionResumeTrigger @@ -114,7 +116,7 @@ const agentSessionRestartActivitySchema = z.object({ const agentSessionResumeMarkerSchema = z.object({ sessionId: markerField, work: agentSessionResumeWorkSchema, - latestUserItemId: markerField.nullable(), + latestUserItemId: markerField.nullable().optional(), recordedAt: z.number().int().nonnegative(), trigger: z.enum(AGENT_SESSION_RESUME_TRIGGERS), providerHandleRoot: markerField, From 5b93c44f6d73a3ce4710c8679e3307234dac2841 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:33:17 -0700 Subject: [PATCH 055/101] fix(runtime): end a transcript stream when its client unsubscribes Desktop: the IPC subscription controller was dropped as soon as the streaming handler returned, which for most streams is right after it binds. A later runtime:unsubscribe then found nothing to abort, so the host kept the subscriber and derived and sent every publish to a channel no one listened to. The controller now lives until the renderer unsubscribes, resubscribes the same id, or goes away. Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe with the stream's frame id, so the host ends that subscriber and leaves a sibling stream on the same socket running. The direct path now passes the frame id the relay path already passed. --- .../rpc-client-stream-registry.test.ts | 14 +++ .../transport/rpc-client-stream-registry.ts | 2 +- .../rpc-client-terminal-subscription.ts | 8 ++ .../ipc/runtime-subscribe-lifecycle.test.ts | 28 +++++- src/main/ipc/runtime.ts | 47 ++++----- ...ructured-agent-session-unsubscribe.test.ts | 98 +++++++++++++++++++ 6 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts diff --git a/mobile/src/transport/rpc-client-stream-registry.test.ts b/mobile/src/transport/rpc-client-stream-registry.test.ts index 0e7d561be6e3..8491ca0336a9 100644 --- a/mobile/src/transport/rpc-client-stream-registry.test.ts +++ b/mobile/src/transport/rpc-client-stream-registry.test.ts @@ -79,6 +79,20 @@ describe('RpcClientStreamRegistry', () => { ]) }) + it('ends one transcript stream on dispose and leaves a sibling on the same socket (U-03)', () => { + const { registry, sent } = createRegistry() + const disposeFirst = registry.subscribe('agentSession.subscribe', { sessionId: 's1' }, () => {}) + registry.subscribe('agentSession.subscribe', { sessionId: 's2' }, () => {}) + const [first] = sent + + disposeFirst() + expect(sent.at(-1)).toMatchObject({ + method: 'agentSession.unsubscribe', + params: { sessionId: 's1', subscriptionId: first!.id } + }) + expect(sent.filter((request) => request.method === 'agentSession.unsubscribe')).toHaveLength(1) + }) + it('keeps a disposed browser tombstone until ready can be unsubscribed', () => { const { registry, sent } = createRegistry() const dispose = registry.subscribe('browser.screencast', { page: 'page-1' }, () => {}) diff --git a/mobile/src/transport/rpc-client-stream-registry.ts b/mobile/src/transport/rpc-client-stream-registry.ts index 68e50a6e6ddf..5c491c382a53 100644 --- a/mobile/src/transport/rpc-client-stream-registry.ts +++ b/mobile/src/transport/rpc-client-stream-registry.ts @@ -213,7 +213,7 @@ export class RpcClientStreamRegistry { this.sendRpc('terminal.unsubscribe', params) } } else { - const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params) + const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params, id) if (unsubscribe) { this.sendRpc(unsubscribe.method, unsubscribe.params) } diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 405f7f1d9e00..2bed2ab0458f 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -53,6 +53,14 @@ export function buildStreamUnsubscribe( } : null } + if (method === 'agentSession.subscribe') { + const sessionId = (params as { sessionId?: unknown }).sessionId + // The host keys each transcript stream by its frame id; without it every stream of the + // session on this socket would end. + return typeof sessionId === 'string' && requestId + ? { method: 'agentSession.unsubscribe', params: { sessionId, subscriptionId: requestId } } + : null + } if (method === 'nativeChat.subscribe') { const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId if (typeof subscriptionId === 'string') { diff --git a/src/main/ipc/runtime-subscribe-lifecycle.test.ts b/src/main/ipc/runtime-subscribe-lifecycle.test.ts index 47f9e6afa4d6..72092b6f5492 100644 --- a/src/main/ipc/runtime-subscribe-lifecycle.test.ts +++ b/src/main/ipc/runtime-subscribe-lifecycle.test.ts @@ -49,12 +49,11 @@ vi.mock('../runtime/rpc/dispatcher', () => ({ subscriptionId: request.id } streams.push(record) - return new Promise((resolve) => { - options.signal.addEventListener('abort', () => { - record.settled = true - resolve() - }) + // The production shape: a streaming handler binds its abort listener and returns at once. + options.signal.addEventListener('abort', () => { + record.settled = true }) + return Promise.resolve() } } })) @@ -267,6 +266,25 @@ describe('runtime:subscribe renderer lifecycle cleanup', () => { expect(stream.signal.aborted).toBe(true) }) + it('aborts a stream whose handler already returned when the renderer unsubscribes (U-01)', async () => { + const harness = createSender(12) + subscribe(harness.sender, 'sub-returned') + const stream = streamFor('sub-returned') + // The dispatch settled: the handler bound its stream and returned. + await Promise.resolve() + await Promise.resolve() + + const unsubscribe = listeners.get('runtime:unsubscribe') + if (!unsubscribe) { + throw new Error('runtime:unsubscribe listener not registered') + } + unsubscribe({ sender: harness.sender }, { subscriptionId: 'sub-returned' }) + + expect(stream.signal.aborted).toBe(true) + stream.emit(FRAME) + expect(harness.sender.send).not.toHaveBeenCalled() + }) + it('scopes colliding subscription ids and unsubscribe to their sender', () => { const firstHarness = createSender(9) const secondHarness = createSender(10) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index ebe21bd53bbc..d8e180fe6a5e 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -102,33 +102,28 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { const controller = new AbortController() senderSubscriptions.set(args.subscriptionId, controller) const channel = `runtime:subscription:${args.subscriptionId}` - const stop = (): void => { - if (senderSubscriptions.get(args.subscriptionId) === controller) { - senderSubscriptions.delete(args.subscriptionId) - } - } - void new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }) - .dispatchStreaming( - { - id: args.subscriptionId, - authToken: 'desktop-ipc', - method: args.method, - params: args.params - }, - (response) => { - if (!controller.signal.aborted && !event.sender.isDestroyed()) { - event.sender.send(channel, JSON.parse(response) as RuntimeRpcResponse) - } - }, - { - signal: controller.signal, - clientId: 'desktop-renderer', - clientKind: 'runtime', - connectionId, - clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + // The controller outlives the handler: most streaming handlers bind and return at once, so + // it lives until `runtime:unsubscribe`, a same-id resubscribe, or the sender going away. + void new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatchStreaming( + { + id: args.subscriptionId, + authToken: 'desktop-ipc', + method: args.method, + params: args.params + }, + (response) => { + if (!controller.signal.aborted && !event.sender.isDestroyed()) { + event.sender.send(channel, JSON.parse(response) as RuntimeRpcResponse) } - ) - .finally(stop) + }, + { + signal: controller.signal, + clientId: 'desktop-renderer', + clientKind: 'runtime', + connectionId, + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + } + ) return { subscribed: true } } ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts new file mode 100644 index 000000000000..b1e2b607d0d4 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts @@ -0,0 +1,98 @@ +// A transcript stream ends when its client says so: the host drops that subscriber, so nothing is +// derived or sent for it any more, and a sibling stream of the same session keeps going. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: 'connection-1' +} + +let rig: RestTestRig +let dispatcher: RpcDispatcher + +function subscriberCount(): number { + const delivery: unknown = Reflect.get(rig.host, 'clientDelivery') + const subscribers: unknown = + typeof delivery === 'object' && delivery !== null ? Reflect.get(delivery, 'subscribers') : null + const bySession: unknown = + typeof subscribers === 'object' && subscribers !== null + ? Reflect.get(subscribers, 'bySession') + : null + return bySession instanceof Map ? (bySession.get(SESSION)?.size ?? 0) : -1 +} + +async function stream(id: string, frames: RpcResponse[]): Promise { + await dispatcher.dispatchStreaming( + { id, authToken: 'token', method: 'agentSession.subscribe', params: { sessionId: SESSION } }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes RpcResponse JSON. + (raw) => frames.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) +} + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. + ({ experimentalStructuredNativeChat: true }) as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +it('drops the subscriber a client unsubscribes, and keeps its sibling (U-02)', async () => { + await foundRestTestChat(rig) + const ended: RpcResponse[] = [] + const kept: RpcResponse[] = [] + await stream('frame-a', ended) + await stream('frame-b', kept) + expect(subscriberCount()).toBe(2) + + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { + id: 'frame-c', + authToken: 'token', + method: 'agentSession.unsubscribe', + params: { sessionId: SESSION, subscriptionId: 'frame-a' } + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes RpcResponse JSON. + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + expect(replies[0]).toMatchObject({ ok: true }) + expect(subscriberCount()).toBe(1) + + const before = ended.length + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await rig.host.send(CALLER, restTestSend('after the unsubscribe', fence)) + await vi.waitFor(() => expect(kept.length).toBeGreaterThan(1)) + // The ended stream's own `end` frame is all it gets. + expect(ended.slice(before).every((frame) => JSON.stringify(frame).includes('"end"'))).toBe(true) +}) From 80ccb332a310460dfd177e4832351bc9529d14a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:10:42 -0700 Subject: [PATCH 056/101] test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. --- ...stration-structured-worker-session.test.ts | 40 +++++++++++++++---- ...orchestration-structured-worker-session.ts | 10 ++++- 2 files changed, 41 insertions(+), 9 deletions(-) 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 c8ebb9eab63e..86568a0435f5 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,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { dispatchWriteFailureReason } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } @@ -224,21 +225,44 @@ describe('structured worker session hold', () => { }) describe('structured worker dispatch preamble', () => { - function hostWithSubmission( - submission: Record, - delivered?: Record - ) { + type PreambleHost = Parameters[0]['host'] + type Settled = Pick + + function submissionOf(settled: Settled): AgentJournalSubmission { + return { + clientMessageId: 'c1', + fence: 7, + payloadFingerprint: 'fingerprint', + providerItemId: null, + submittedAt: 1, + resolvedAt: null, + ...settled + } + } + + function hostWithSubmission(submission: Settled, delivered?: Settled): PreambleHost { return { deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, - send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }), + send: async () => ({ + ok: true, + replayed: false, + fence: 7, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { clientMessageId: 'c1', submission: submissionOf(submission) } + }), // What the submission settled as while the worker's agent started; undefined when the // start outlasted the wait. waitForSendSettlement: async () => - delivered ? { value: { clientMessageId: 'c1', submission: delivered } } : undefined - } as never + delivered + ? { + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { clientMessageId: 'c1', submission: submissionOf(delivered) } + } + : undefined + } } - const send = (host: never) => + const send = (host: PreambleHost) => sendStructuredWorkerPreamble({ host, sessionId: 's1', dispatchId: 'd1', preamble: 'spec' }) it('reports the preamble delivered only on an accepted submission', async () => { 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 3ac972138697..06f657924b12 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -214,10 +214,18 @@ export async function discardStructuredWorkerSession( retireSettledStructuredWorkerTab(sessionId, runtime) } +/** What a preamble send reads of the host. */ +type StructuredWorkerPreambleHost = Pick< + StructuredAgentSessionHost, + 'send' | 'waitForSendSettlement' +> & { + deps: { store: { getRecord: (sessionId: string) => { lease: { runtimeFence: number } } | null } } +} + /** Delivers the dispatch preamble as the worker's first turn. `pending`: the worker's agent had * not taken it within the wait; the host still holds it for that agent, and never re-sends it. */ export async function sendStructuredWorkerPreamble(args: { - host: StructuredAgentSessionHost + host: StructuredWorkerPreambleHost sessionId: string dispatchId: string preamble: string From 0a3f25552ea6eb15c6fc10870e756f8658534765 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:57:34 -0700 Subject: [PATCH 057/101] fix(native-chat): one fact ends a restart offer: the chat moved on since the restart The offer is live while no other message has been accepted in the chat since the restart and its agent has not proved a start since. The offer list, the resume's reservation check and the continuation's acceptance check all read that one fact, so a message whose start then failed withdraws the offer too, and a stale click finds nothing to act on. The fact is read off the conversation's open handle, which the restart closed, so it is retired durably whenever it may have changed: a message accepted, a start proven. A close and reopen within the same run therefore cannot bring the offer back. A continuation rejected before it reached the agent does not count, so a retry after a failed start still runs. Deleted: the quit-time gate on withdrawal, which changed nothing because the withdrawal and the quit's own offer write share one queue; the per-action "withdrawn" flag and the separate acceptance check it paired with. --- ...tructured-agent-session-client-delivery.ts | 4 +- ...red-agent-session-conversation-lifetime.ts | 1 - ...ctured-agent-session-host-teardown.test.ts | 2 +- .../structured-agent-session-host-teardown.ts | 3 +- .../structured-agent-session-host.ts | 16 ++- ...ctured-agent-session-restart-candidates.ts | 3 + ...t-session-restart-offer-withdrawal.test.ts | 104 +++++++--------- ...-agent-session-restart-offer-withdrawal.ts | 115 +++++++++--------- ...ed-agent-session-restart-ownership.test.ts | 13 +- ...tured-agent-session-restart-resume-host.ts | 37 +++--- ...ctured-agent-session-restart-resume-set.ts | 17 ++- ...ent-session-restart-resume-test-harness.ts | 3 +- ...red-agent-session-restart-resume-wiring.ts | 4 +- ...tured-agent-session-restart-resume.test.ts | 6 +- .../structured-agent-session-status-feed.ts | 12 +- 15 files changed, 164 insertions(+), 176 deletions(-) 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 48f1f5f6e82e..469769cb57ec 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 @@ -28,13 +28,13 @@ export class StructuredAgentSessionClientDelivery { now: () => number, deps: () => StructuredAgentSessionHostDeps, private readonly onJournalActivity?: (sessionId: string) => void, - onOwnedEdge?: (sessionId: string) => void + onAgentStarted?: (sessionId: string) => void ) { this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps, - ...(onOwnedEdge ? { onOwnedEdge } : {}) + ...(onAgentStarted ? { onAgentStarted } : {}) }) this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 963917c45631..a71fa103cbb8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -80,7 +80,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { return { idleSweep, stopAgent, - isDisposed: (): boolean => disposed, /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index d82910bff378..5fd0440a1fe1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -13,8 +13,8 @@ describe('structured agent-session host teardown', () => { recordResumeMarkers: noop }) expect(phases.map((phase) => phase.name)).toEqual([ - 'dispose-idle-sweep', 'begin-resume-markers', + 'dispose-idle-sweep', 'stop-lease-renewal', 'drain-attaches', 'evict-owned-sessions', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 175599608753..33149381c81d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -59,8 +59,6 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { recordResumeMarkers: () => Promise }): StructuredAgentSessionTeardownPhase[] { return [ - // First: once quit begins, a start that lands must not withdraw an offer teardown captures. - { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'begin-resume-markers', run: () => { @@ -71,6 +69,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { } } }, + { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { 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 909d0ed4c004..ca264ac9087e 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 @@ -75,7 +75,7 @@ export class StructuredAgentSessionHost { () => this.now(), () => this.deps, (sessionId) => this.sessions.touch(sessionId), - (sessionId) => this.restartResume.onOwnedEdge(sessionId) + (sessionId) => this.restartResume.recheck(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -152,10 +152,11 @@ export class StructuredAgentSessionHost { now: () => this.now(), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) - this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { - ...structuredAgentSessionRestartResumeSurfaces(this, this.now), - isDisposed: () => this.lifetime.isDisposed() - }) + this.restartResume = createStructuredAgentSessionRestartResume( + deps, + this.sessions, + structuredAgentSessionRestartResumeSurfaces(this, this.now) + ) this.lifetime = createStructuredAgentSessionConversationLifetime({ context: () => this.lifetimeContext(), sessions: this.sessions, @@ -265,7 +266,10 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), + wakeDelivery: (sessionId) => { + this.conversationDelivery.loop.wake(sessionId) + this.restartResume.recheck(sessionId) + }, stopAgent: this.lifetime.stopAgent, now: () => this.now() } 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 a9251cd2824e..fd007c8fd5b8 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 @@ -28,12 +28,15 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter + /** Whether the chat moved on since the restart; see the offer withdrawal. */ + movedOn: (sessionId: string) => boolean }): StructuredAgentSessionRestartCandidateReader { return (markers, leaseState) => structuredAgentSessionResumableSet({ markers, getRecord: deps.getRecord, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + movedOn: deps.movedOn, latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt( deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] 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 604155bc4989..66702016edc8 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,6 +1,6 @@ -// A restart offer ends when the chat's agent is started again, other than by its own continuation, -// and a message and a continuation racing to be first are decided at acceptance. Each case reads -// the offer list and the capsule, never only an in-memory set. +// A restart offer ends when the chat moves on after the restart: another message accepted, or its +// agent started, other than by the offer's own continuation. Each case reads the offer list and the +// capsule, never only an in-memory set. import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -27,6 +27,9 @@ afterEach(() => vi.restoreAllMocks()) const SUPERSEDED = 'agent_session_restart_work_superseded' +/** A start under a loaded machine: reconcile, acquire, hand over. */ +const COLD_START = { timeout: 10_000 } + async function offered(work: 'turn' | 'submission' = 'turn') { const state = await interruptedRestart(work, false) expect(await state.host.restartResume.list()).toHaveLength(1) @@ -62,7 +65,7 @@ it('withdraws the offer when /compact starts the agent at rest (R-01)', async () expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() - await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) expect(await host.restartResume.list()).toEqual([]) }) @@ -139,8 +142,8 @@ it('reads an older build’s marker whose message id no longer matches, and one expect(parseAgentSessionResumeMarker(withoutId)).toEqual(withoutId) await host.send(CALLER, userSend('Carry on')) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) }) it('still writes the message id the previous build requires on every marker', async () => { @@ -151,42 +154,49 @@ it('still writes the message id the previous build requires on every marker', as expect(capsule.entries[0]?.marker).toHaveProperty('latestUserItemId') }) -it('keeps the offer when the send that would start the agent fails to (R-08)', async () => { - const { host, root, acquire } = await offered() - acquire.mockRejectedValueOnce(new Error('Not signed in')) +/** Sends a message whose start fails, and waits for it to be rejected. */ +async function sendWhoseStartFails(state: Awaited>) { + state.acquire.mockRejectedValueOnce(new Error('Not signed in')) const params = userSend('while signed out') - - await host.send(CALLER, params) - + await state.host.send(CALLER, params) await vi.waitFor(async () => expect( - (await host.journalSnapshot(SESSION)).submissions.find( + (await state.host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === params.envelope.clientOperationId ) ).toMatchObject({ dispatchState: 'rejected' }) ) - expect(await host.restartResume.list()).toHaveLength(1) - expect(await offersIn(root)).toHaveLength(1) -}) +} -it('keeps the offer a quit captures when a start lands while quitting (R-09)', async () => { - const { host, root, acquire } = await offered() - const starting = Promise.withResolvers() - const acquired = Promise.withResolvers() - const spawn = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - acquired.resolve() - await starting.promise - return spawn(input) +// A message the user sent is activity even when its start then failed. +it('withdraws the offer when the user sends a message whose start then fails (R-08)', async () => { + const state = await offered() + const { host, root, dispatch } = state + + await sendWhoseStartFails(state) + + expect(await host.restartResume.list()).toEqual([]) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) + // A click from a surface that still shows the offer finds nothing to act on. + expect(await host.restartResume.continueAfterRestart([SESSION], 'stale-click')).toMatchObject({ + resumed: [], + continued: [] }) - await host.send(CALLER, userSend('started as the app quits')) - await acquired.promise + expect(dispatch).not.toHaveBeenCalled() +}) - const quitting = host.flushAllStreamedEvents({ trigger: 'quit' }) - starting.resolve() - await quitting +// The open handle is only a cache: closing it and reading the chat again must not bring back an +// offer the user's message withdrew. +it('keeps the offer withdrawn after the chat is closed and read again', async () => { + const state = await offered() + const { host, root } = state + await sendWhoseStartFails(state) - expect(await offersIn(root)).toHaveLength(1) + await host.close(SESSION) + await host.revealSession(SESSION) + + expect(await host.restartResume.list()).toEqual([]) + expect(await offersIn(root)).toEqual([]) }) // Resume that runs by itself at launch goes through the same call a click does, and the same rule @@ -219,7 +229,7 @@ it("refuses an automatic continuation when the user's message was accepted first const result = await host.restartResume.continueAfterRestart(undefined, 'launch') expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) expect(sentTexts(dispatch)).toEqual(['A new request']) // Nothing the user did failed: no row reached the reader, and nothing is kept. expect(await statusNotes(host)).toEqual([]) @@ -247,37 +257,9 @@ it('delivers a clicked continuation and a message sent right after it, in the or const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2), COLD_START) const [first, second] = sentTexts(dispatch) expect(first).not.toBe('And then this') expect(second).toBe('And then this') expect(await offersIn(root)).toEqual([]) }) - -// The user's message came first but its start failed, so no start withdrew the offer: Resume is -// still refused quietly, and nothing is filed for the user to act on. -it("files nothing when Resume loses to the user's message whose start failed", async () => { - const { host, root, acquire, dispatch } = await offered() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - const params = userSend('while signed out') - await host.send(CALLER, params) - await vi.waitFor(async () => - expect( - (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === params.envelope.clientOperationId - ) - ).toMatchObject({ dispatchState: 'rejected' }) - ) - expect(await host.restartResume.list()).toHaveLength(1) - - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect(result).toMatchObject({ - continued: [{ outcome: 'refused', reason: SUPERSEDED }], - sessions: [], - failed: [] - }) - expect(dispatch).not.toHaveBeenCalled() - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) - expect(await offersIn(root)).toEqual([]) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index b3983c7faf27..14c410553e47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -1,89 +1,92 @@ -// When a restart offer ends without being acted on: the chat's agent was started again. +// When a restart offer ends without being acted on: the chat moved on. // -// The fact is the one the agent-status store already carries — a chat is host-owned exactly while -// its agent runs — read at its not-owned → owned edge. Restored and replayed rows carry no running -// agent, and opening a chat starts none, so only a real start withdraws. A resume action's own -// start does not; any other start during that action withdraws the offer and refuses the action. -// Two acts racing to be first — a message and a continuation — are decided at acceptance instead: -// the first one accepted since the restart wins. +// One derived fact decides it, for the offer list and for the continuation's own acceptance alike: +// since the restart, another message was accepted in the chat, or its agent proved a start. Both +// are read off what the conversation's open handle holds, which the restart closed — a restored +// row, a replayed row or a view carries neither, so opening a chat withdraws nothing. The rest of +// a resume action does not count: its own continuation, and the start that continuation waits on. +// +// The handle is only a cache, so its answer does not outlive a close: each time the fact may have +// changed — a message accepted, a start proven — the offer is retired durably too. import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< typeof createStructuredAgentSessionRestartOfferWithdrawal > +export type StructuredAgentSessionRestartOfferSession = Pick< + StructuredAgentSessionHostSession, + 'journal' | 'child' | 'lastEndedChild' +> + export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { - sessions: ReadonlyMap + sessions: ReadonlyMap capsule?: Pick - /** Quit has begun: a start that lands now must not touch the offers teardown is capturing. */ - isDisposed: () => boolean /** Whether this message was a restart continuation, by who sent it. */ isContinuation: (clientMessageId: string) => boolean now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise }) { - const actions = new Map() + /** The continuation each running resume action sends, by chat. */ + const actions = new Map() - /** The offer and any failure record go. Advisory: a failed write is logged, never raised. */ - const retire = (sessionId: string): void => { - const capsule = deps.capsule - if (!capsule) { - return + const movedOn = (sessionId: string): boolean => { + const session = deps.sessions.get(sessionId) + if (!session) { + return false + } + const own = actions.get(sessionId) + const submissions = session.journal.submissions() + const accepted = submissions.some( + (submission) => + submission.clientMessageId !== own && + !session.journal.wroteBeforeOpen(submission.acceptedSequence) && + // A continuation that was rejected never reached the agent: a retry sends a new one. + !( + submission.dispatchState === 'rejected' && deps.isContinuation(submission.clientMessageId) + ) + ) + if (accepted) { + return true } - void deps - .enqueue(() => capsule.dismiss([sessionId], deps.now())) - .catch(() => { - console.warn('[structured-agent-session] withdrawing a restart offer failed') - }) + // Proven, not merely spawned: a start that failed during startup never ran the agent. + const started = + session.child?.phase === 'ready' || + (session.lastEndedChild !== undefined && !session.lastEndedChild.duringStartup) + // The action's own start is the one its continuation is still waiting on. + const ownStart = + own !== undefined && + submissions.find((submission) => submission.dispatchState === 'pending')?.clientMessageId === + own + return started && !ownStart } return { + movedOn, /** A resume action holds the chat from its reservation until it settles. */ begin: (sessionId: string, continuationId: string): (() => void) => { - const action = { continuationId, withdrawn: false } - actions.set(sessionId, action) + actions.set(sessionId, continuationId) return () => { - if (actions.get(sessionId) === action) { + if (actions.get(sessionId) === continuationId) { actions.delete(sessionId) } } }, - /** Another start withdrew the offer while an action held it. */ - withdrawn: (sessionId: string): boolean => actions.get(sessionId)?.withdrawn === true, - /** A message was accepted since the restart: by this conversation's open handle, which the - * restart closed. A continuation that was rejected never reached the agent, so it does not - * count: a retry sends a new one. */ - acceptedSinceRestart: (sessionId: string): boolean => { - const journal = deps.sessions.get(sessionId)?.journal - return (journal?.submissions() ?? []).some( - (submission) => - !journal?.wroteBeforeOpen(submission.acceptedSequence) && - !( - submission.dispatchState === 'rejected' && - deps.isContinuation(submission.clientMessageId) - ) - ) - }, - onOwnedEdge: (sessionId: string): void => { - if (deps.isDisposed()) { + /** The fact may have changed: retire the offer and any failure record durably if it holds. + * Advisory: a failed write is logged, never raised. */ + recheck: (sessionId: string): void => { + const capsule = deps.capsule + if (!capsule || !movedOn(sessionId)) { return } - const action = actions.get(sessionId) - if (action) { - // The action's own start is the one whose oldest undelivered message is its continuation. - const oldest = deps.sessions - .get(sessionId) - ?.journal.submissions() - .find((submission) => submission.dispatchState === 'pending') - if (oldest?.clientMessageId === action.continuationId) { - return - } - action.withdrawn = true - } - retire(sessionId) + void deps + .enqueue(() => capsule.dismiss([sessionId], deps.now())) + .catch(() => { + console.warn('[structured-agent-session] withdrawing a restart offer failed') + }) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 522fba0c70cf..a66eaa074320 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -324,14 +324,11 @@ it('fails closed on corrupt recovery storage while an ordinary send still works' await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - // list; the action's read of offers and of failures; the post-action refresh of both; and the - // send's start, which cannot withdraw an offer it cannot read. - await vi.waitFor(() => - expect(warning).toHaveBeenLastCalledWith( - '[structured-agent-session] withdrawing a restart offer failed' - ) - ) - expect(warning).toHaveBeenCalledTimes(6) + // list; the action's read of offers and of failures; the post-action refresh of both. The send + // cannot withdraw an offer it cannot read either, and says so. + const withdrawing = '[structured-agent-session] withdrawing a restart offer failed' + await vi.waitFor(() => expect(warning).toHaveBeenLastCalledWith(withdrawing)) + expect(warning.mock.calls.filter(([message]) => message !== withdrawing)).toHaveLength(5) warning.mockRestore() }) 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 6e965f7ac9ac..47661791c4b1 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 @@ -9,7 +9,6 @@ import type { AgentSessionResumeMarker, AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' import { @@ -34,7 +33,10 @@ import { type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' -import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { + createStructuredAgentSessionRestartOfferWithdrawal, + type StructuredAgentSessionRestartOfferSession +} from './structured-agent-session-restart-offer-withdrawal' import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, type StructuredAgentSessionRestartResumeSurfaces @@ -42,7 +44,7 @@ import { import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -type LiveSession = { journal: AgentSessionJournal; child: { fence: number } | null } +type LiveSession = StructuredAgentSessionRestartOfferSession export type StructuredAgentSessionRestartResume = { /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation @@ -65,8 +67,8 @@ export type StructuredAgentSessionRestartResume = { }> /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ dismiss: (sessionIds?: readonly string[]) => Promise - /** The chat's agent was started: its offer ends unless the start is a resume's own. */ - onOwnedEdge: (sessionId: string) => void + /** A message was accepted, or the agent proved a start: the chat may have moved on. */ + recheck: (sessionId: string) => void } export function createStructuredAgentSessionRestartResume( @@ -81,11 +83,6 @@ export function createStructuredAgentSessionRestartResume( const admission = new StructuredAgentSessionResumeAdmission() const enqueueRecoveryOperation = createStructuredAgentSessionRestartOperationQueue() - const derive = createStructuredAgentSessionRestartCandidateReader({ - sessions, - getRecord: deps.store.getRecord, - adapter: deps.adapter - }) const witnesses = createStructuredAgentSessionRestartWitnesses({ sessions, getRecord: deps.store.getRecord, @@ -98,7 +95,6 @@ export function createStructuredAgentSessionRestartResume( const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ sessions, ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), - isDisposed: surfaces.isDisposed, isContinuation: (clientMessageId) => deps.store.getOperationRow( STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, @@ -107,6 +103,12 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) + const derive = createStructuredAgentSessionRestartCandidateReader({ + sessions, + getRecord: deps.store.getRecord, + adapter: deps.adapter, + movedOn: withdrawal.movedOn + }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), getRecord: deps.store.getRecord, @@ -145,10 +147,7 @@ export function createStructuredAgentSessionRestartResume( deps.store.getRecord(sessionId) ? structuredAgentSessionConversationFence(deps.store, sessionId) : null, - stillResumable: (marker) => - derive([marker], 'may-be-held').candidates.length === 1 && - !withdrawal.withdrawn(marker.sessionId) && - !withdrawal.acceptedSinceRestart(marker.sessionId) + stillResumable: (marker) => derive([marker], 'may-be-held').candidates.length === 1 } /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at @@ -194,11 +193,7 @@ export function createStructuredAgentSessionRestartResume( admission, consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) - return ( - marker !== undefined && - derive([marker], 'may-be-held').candidates.length === 1 && - !withdrawal.withdrawn(sessionId) - ) + return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 }, resume: async (sessionId) => { const marker = markersBySession.get(sessionId) @@ -313,6 +308,6 @@ export function createStructuredAgentSessionRestartResume( // dismissal. A later capture is a new interruption and may create a fresh offer normally. dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), continueAfterRestart, - onOwnedEdge: withdrawal.onOwnedEdge + recheck: withdrawal.recheck } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 8b6b0ca1c7f6..8e6cb0f64c90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -7,10 +7,9 @@ // restored thread), and every reading of those rewrites as "the work is done" dropped chats that // were owed a resume. // -// An offer ends when the chat's agent is started again, other than by its own continuation; the -// host retires it at that start. The one ending this predicate can see — the conversation forked — -// is reported back as `superseded` so the caller DELETES the record rather than filtering it -// forever. What remains are structural checks that are not about work at all: the record still +// An offer ends when the chat moves on after the restart — another message accepted, or its agent +// started — or when the conversation forked. Both are reported back as `superseded` so the caller +// DELETES the record rather than filtering it forever. What remains are structural checks that are not about work at all: the record still // exists and this build supports it, and the lease is free. import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -65,8 +64,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa export type StructuredAgentSessionResumableSet = { candidates: StructuredAgentSessionResumeCandidate[] - /** Markers the chat has provably moved past: a forked conversation. Every ending deletes: the - * caller retires these rather than re-filtering them forever. */ + /** Markers the chat has provably moved past, or whose conversation forked. Every ending deletes: + * the caller retires these rather than re-filtering them forever. */ superseded: AgentSessionResumeMarker[] } @@ -75,6 +74,8 @@ export type StructuredAgentSessionResumeSetInput = { getRecord: (sessionId: string) => AgentSessionRecord | null supportsRecord: (record: AgentSessionRecord) => boolean latestPrompt: (sessionId: string) => string + /** Whether the chat moved on since the restart; false when its journal is not open here. */ + movedOn: (sessionId: string) => boolean /** * Whether the lease must be free. * @@ -111,6 +112,10 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } + if (input.movedOn(marker.sessionId)) { + superseded.push(marker) + continue + } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) candidates.push({ sessionId: marker.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 38cb17ba42fd..e359e31157b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -208,6 +208,7 @@ export function resumableSet(input: { markers: input.markers, getRecord: () => record(input.chain === undefined ? {} : { chain: input.chain }), supportsRecord: () => true, - latestPrompt: () => 'fix the auth bug' + latestPrompt: () => 'fix the auth bug', + movedOn: () => false }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 65235738d11b..4004502e4081 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -31,8 +31,6 @@ export type StructuredAgentSessionRestartResumeSurfaces = { ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void now: () => number - /** Quit has begun; see the offer withdrawal. */ - isDisposed: () => boolean } /** The caller key the continuation sends under, so its writes are attributable to Orca itself. */ @@ -62,7 +60,7 @@ type RestartResumeHostBindings = { export function structuredAgentSessionRestartResumeSurfaces( host: RestartResumeHostBindings, now: () => number -): Omit { +): StructuredAgentSessionRestartResumeSurfaces { return { revealSession: host.revealSession, send: (params) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 305559851115..36090467b412 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -450,7 +450,8 @@ describe('the resumable set', () => { markers: [marker({ providerHandleRoot: CLAUDE_ROOT })], getRecord: () => claudeRecord('5aed93d6-advanced-leaf'), supportsRecord: () => true, - latestPrompt: () => '' + latestPrompt: () => '', + movedOn: () => false }) expect(candidates).toHaveLength(1) @@ -464,7 +465,8 @@ describe('the resumable set', () => { markers: [forked], getRecord: () => claudeRecord(null, 'prov-session-2'), supportsRecord: () => true, - latestPrompt: () => '' + latestPrompt: () => '', + movedOn: () => false }) expect(set.candidates).toEqual([]) 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 5e026d915a59..92161a5bca07 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 @@ -57,8 +57,8 @@ export type StructuredAgentSessionStatusFeedDeps = { /** Live provider-owned background tasks for the summary, so session lists can * render subagent children. Optional: a provider without the hook projects none. */ readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined - /** The session's agent was started: its row went from not host-owned to host-owned. */ - onOwnedEdge?: (sessionId: string) => void + /** The session's agent proved a start: its row's phase became `ready`. */ + onAgentStarted?: (sessionId: string) => void } function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { @@ -116,7 +116,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } - onOwnedEdge?: (sessionId: string) => void + onAgentStarted?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ sessions: args.sessions, @@ -127,7 +127,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. statusSink: () => args.deps().statusSink, - ...(args.onOwnedEdge ? { onOwnedEdge: args.onOwnedEdge } : {}) + ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}) }) } @@ -224,8 +224,8 @@ export class StructuredAgentSessionStatusFeed { this.published.set(sessionId, summary) this.sink(summary, session.params.location) this.broadcast({ type: 'status', session: summary }) - if (summary.hostExecutionOwned && !previous?.hostExecutionOwned) { - this.deps.onOwnedEdge?.(sessionId) + if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { + this.deps.onAgentStarted?.(sessionId) } try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) From 4c458654302a9feaef291c1413e3706cc7e30303 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:06:21 -0700 Subject: [PATCH 058/101] test(native-chat): an older build reads the restart offer this build records The offer lives in a file the previous release reads after a downgrade. Pin that against the pinned release's own capsule, and run the lane when the marker or the capsule changes. --- .github/workflows/pr.yml | 1 + config/scripts/pr-code-change-scope.mjs | 2 + ...ssion-resume-marker-downgrade.unit.test.ts | 52 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1b52aac7571d..6125304af8bf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -757,6 +757,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts + tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index babe8e8c7e05..b1a8e17726a1 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -147,6 +147,8 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/native-chat/agent-session-journal/', 'src/main/native-chat/agent-session-wire/', 'src/main/runtime/agent-session-record-store', + 'src/main/runtime/agent-session-recovery-capsule', + 'src/shared/agent-session-resume-marker', 'src/main/runtime/rpc/dispatcher', 'src/main/runtime/rpc/methods/agent-launch', 'src/main/runtime/rpc/methods/ai-vault.ts', diff --git a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts new file mode 100644 index 000000000000..f7f23d528aaf --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { structuredAgentSessionWorkingAtStop } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown' +import { + journal, + NOW, + record, + SESSION, + TEARDOWN_CURRENT, + turnItem +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness' +import { AgentSessionRecoveryCapsule } from '../../../src/main/runtime/agent-session-recovery-capsule' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release whose marker parser still requires the chat's newest message id. +const BASELINE_REF = 'v1.4.211' + +test('an older build reads the restart offer this build records at quit', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-resume-marker-downgrade-')) + try { + // This build: the offer teardown takes for a chat whose turn was running. + const marker = structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }, + getRecord: () => record(), + backgroundTasks: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + if (!marker) { + throw new Error('this build recorded no offer for a running turn') + } + await new AgentSessionRecoveryCapsule(directory).record([marker], NOW) + + // The older build, after a downgrade, still lists it. + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-recovery-capsule.ts' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the constructor and `list` called below; a missing one fails the test. + const OldCapsule = baseline.AgentSessionRecoveryCapsule as new (directory: string) => { + list: (now: number) => Promise<{ sessionId: string }[]> + } + expect(await new OldCapsule(directory).list(NOW)).toMatchObject([{ sessionId: SESSION }]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) From 1887befe4be6a175ece071f530c34aab5c3d7971 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:26:24 -0700 Subject: [PATCH 059/101] fix(native-chat): read a restart offer against where the journal stood when it was taken "Since the restart" was read off the conversation's open handle, which the idle sweep closes: after a reopen, a message the user had already sent looked older than the handle and the withdrawn offer came back. The offer now records the journal position (epoch and sequence) at the moment it is taken, and a message accepted after that position, or a journal on another epoch, means the chat moved on. That is derived from the journal, so it holds across any number of closes and reopens. An older build's offer has no position; only a start withdraws it. Because the message half is now durable, the offer is no longer rewritten in the recovery file on every accepted message; a proven start still writes it, since only the host that saw the start knows of it. --- .../structured-agent-session-host.ts | 7 +- ...ctured-agent-session-restart-candidates.ts | 4 +- ...ssion-restart-interruption-test-harness.ts | 7 +- ...gent-session-restart-offer-endings.test.ts | 10 ++- ...t-session-restart-offer-withdrawal.test.ts | 81 +++++++++++++++++-- ...-agent-session-restart-offer-withdrawal.ts | 70 ++++++++-------- ...tured-agent-session-restart-resume-host.ts | 6 +- ...ctured-agent-session-restart-resume-set.ts | 6 +- ...tured-agent-session-restart-resume.test.ts | 2 + ...tured-agent-session-working-at-teardown.ts | 1 + src/shared/agent-session-resume-marker.ts | 19 ++++- 11 files changed, 153 insertions(+), 60 deletions(-) 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 ca264ac9087e..c19596bb9ce7 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 @@ -75,7 +75,7 @@ export class StructuredAgentSessionHost { () => this.now(), () => this.deps, (sessionId) => this.sessions.touch(sessionId), - (sessionId) => this.restartResume.recheck(sessionId) + (sessionId) => this.restartResume.onAgentStarted(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -266,10 +266,7 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), - wakeDelivery: (sessionId) => { - this.conversationDelivery.loop.wake(sessionId) - this.restartResume.recheck(sessionId) - }, + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: this.lifetime.stopAgent, now: () => this.now() } 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 fd007c8fd5b8..50008ce79c44 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 @@ -28,8 +28,8 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter - /** Whether the chat moved on since the restart; see the offer withdrawal. */ - movedOn: (sessionId: string) => boolean + /** Whether the chat moved on since the offer was taken; see the offer withdrawal. */ + movedOn: (marker: AgentSessionResumeMarker) => boolean }): StructuredAgentSessionRestartCandidateReader { return (markers, leaseState) => structuredAgentSessionResumableSet({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d37e73aa90d5..4b10484a24bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -110,6 +110,8 @@ export async function interruptedRestart( hostId: 'local' }) const closeSession = vi.fn(async () => true) + // The relaunch comes after the quit that recorded the offer. + const clock = { now: NOW + 1 } const host = new StructuredAgentSessionHost({ store, adapter: { @@ -130,8 +132,7 @@ export async function interruptedRestart( mintSpawnToken: () => 'spawn-next', probeOwner: async () => ({ outcome: 'pid-absent' }), recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), - // The relaunch comes after the quit that recorded the offer. - now: () => NOW + 1 + now: () => clock.now }) replaceHostTestState({ store, host }) previous.acquire.mockClear() @@ -141,7 +142,7 @@ export async function interruptedRestart( await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) - return { ...hostTestState(), host, store, closeSession, marker } + return { ...hostTestState(), host, store, closeSession, marker, clock } } export async function statusNotes(host: StructuredAgentSessionHost) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index 2973be593ef4..aefa34f4b673 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -24,9 +24,13 @@ it('deletes the offer once the user sends their own message in that chat', async const body = hostTestMessage('Never mind, do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - await vi.waitFor(async () => { - expect(await capsule.list(NOW)).toEqual([]) - }) + // Written when the agent's start is proven: a cold start. + await vi.waitFor( + async () => { + expect(await capsule.list(NOW)).toEqual([]) + }, + { timeout: 10_000 } + ) expect(await host.restartResume.list()).toEqual([]) }) 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 66702016edc8..159a2ebc846e 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 @@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE @@ -17,6 +17,9 @@ import { statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { sweepOnce } from './structured-agent-session-rest-test-rig' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -185,14 +188,16 @@ it('withdraws the offer when the user sends a message whose start then fails (R- expect(dispatch).not.toHaveBeenCalled() }) -// The open handle is only a cache: closing it and reading the chat again must not bring back an -// offer the user's message withdrew. -it('keeps the offer withdrawn after the chat is closed and read again', async () => { +// The idle sweep closes a chat's open handle, and the next read opens a new one: the user's message +// still counts, because it is read against where the journal stood when the offer was taken. +it('keeps the offer withdrawn after the idle sweep closes the chat and it is read again', async () => { const state = await offered() - const { host, root } = state + const { host, root, clock } = state await sendWhoseStartFails(state) + clock.now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 - await host.close(SESSION) + await sweepOnce(host) + expect(host.hasSession(SESSION)).toBe(false) await host.revealSession(SESSION) expect(await host.restartResume.list()).toEqual([]) @@ -263,3 +268,67 @@ it('delivers a clicked continuation and a message sent right after it, in the or expect(second).toBe('And then this') expect(await offersIn(root)).toEqual([]) }) + +describe('reading the chat against where the offer was taken', () => { + const TAKEN = { epoch: 'epoch-1', sequence: 5 } + + function movedOn( + input: { + epoch?: string + submissions?: { clientMessageId: string; acceptedSequence: number; dispatchState: string }[] + continuations?: string[] + }, + journalCursor: { epoch: string; sequence: number } | null = TAKEN + ): boolean { + const session = { + child: null, + journal: { + cursor: () => ({ epoch: input.epoch ?? TAKEN.epoch, sequence: 9 }), + submissions: () => input.submissions ?? [] + } + } + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. + sessions: new Map([[SESSION, session as never]]), + isContinuation: (id) => input.continuations?.includes(id) === true, + now: () => NOW, + enqueue: (operation) => operation() + }) + return withdrawal.movedOn({ + sessionId: SESSION, + work: { kind: 'turn', id: 'turn-1' }, + recordedAt: NOW, + trigger: 'quit', + providerHandleRoot: 'codex:"thread"', + teardownId: 'teardown-1', + ...(journalCursor ? { journalCursor } : {}) + }) + } + + it('counts a message accepted after that position, and nothing before it', () => { + const at = (acceptedSequence: number) => ({ + clientMessageId: `m-${acceptedSequence}`, + acceptedSequence, + dispatchState: 'rejected' + }) + expect(movedOn({ submissions: [at(4), at(5)] })).toBe(false) + expect(movedOn({ submissions: [at(4), at(6)] })).toBe(true) + }) + + it('counts a journal on another epoch as moved on', () => { + expect(movedOn({ epoch: 'epoch-2' })).toBe(true) + }) + + it('does not count a rejected continuation, so a retry still runs', () => { + const rejected = { clientMessageId: 'c-1', acceptedSequence: 7, dispatchState: 'rejected' } + expect(movedOn({ submissions: [rejected], continuations: ['c-1'] })).toBe(false) + expect( + movedOn({ submissions: [{ ...rejected, dispatchState: 'accepted' }], continuations: ['c-1'] }) + ).toBe(true) + }) + + it('leaves an older build’s offer, which recorded no position, to a start', () => { + const after = { clientMessageId: 'm-7', acceptedSequence: 7, dispatchState: 'accepted' } + expect(movedOn({ submissions: [after], epoch: 'epoch-2' }, null)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 14c410553e47..67eafcd1effd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -1,15 +1,15 @@ // When a restart offer ends without being acted on: the chat moved on. // // One derived fact decides it, for the offer list and for the continuation's own acceptance alike: -// since the restart, another message was accepted in the chat, or its agent proved a start. Both -// are read off what the conversation's open handle holds, which the restart closed — a restored -// row, a replayed row or a view carries neither, so opening a chat withdraws nothing. The rest of -// a resume action does not count: its own continuation, and the start that continuation waits on. -// -// The handle is only a cache, so its answer does not outlive a close: each time the fact may have -// changed — a message accepted, a start proven — the offer is retired durably too. +// since the offer was taken, another message was accepted in the chat, or its agent proved a start. +// A message is read against the journal position the offer recorded, so the answer survives any +// number of handle closes. A start is known only to the host that saw it, so it is also written +// to the recovery file when it happens. A restored row, a replayed row or a view carries neither, +// so opening a chat withdraws nothing. The rest of a resume action does not count: its own +// continuation, and the start that continuation waits on. import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< @@ -33,35 +33,38 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { /** The continuation each running resume action sends, by chat. */ const actions = new Map() - const movedOn = (sessionId: string): boolean => { - const session = deps.sessions.get(sessionId) + /** The action's own start is the one its continuation is still waiting on. */ + const ownStart = (session: StructuredAgentSessionRestartOfferSession, own: string | undefined) => + own !== undefined && + session.journal.submissions().find((submission) => submission.dispatchState === 'pending') + ?.clientMessageId === own + + const movedOn = (marker: AgentSessionResumeMarker): boolean => { + const session = deps.sessions.get(marker.sessionId) if (!session) { return false } - const own = actions.get(sessionId) - const submissions = session.journal.submissions() - const accepted = submissions.some( - (submission) => - submission.clientMessageId !== own && - !session.journal.wroteBeforeOpen(submission.acceptedSequence) && - // A continuation that was rejected never reached the agent: a retry sends a new one. - !( - submission.dispatchState === 'rejected' && deps.isContinuation(submission.clientMessageId) - ) - ) - if (accepted) { - return true - } + const own = actions.get(marker.sessionId) + const taken = marker.journalCursor + // An older build's offer recorded no position: only a start withdraws it. + const accepted = + taken !== undefined && + (session.journal.cursor().epoch !== taken.epoch || + session.journal.submissions().some( + (submission) => + submission.clientMessageId !== own && + (submission.acceptedSequence ?? 0) > taken.sequence && + // A continuation that was rejected never reached the agent: a retry sends a new one. + !( + submission.dispatchState === 'rejected' && + deps.isContinuation(submission.clientMessageId) + ) + )) // Proven, not merely spawned: a start that failed during startup never ran the agent. const started = session.child?.phase === 'ready' || (session.lastEndedChild !== undefined && !session.lastEndedChild.duringStartup) - // The action's own start is the one its continuation is still waiting on. - const ownStart = - own !== undefined && - submissions.find((submission) => submission.dispatchState === 'pending')?.clientMessageId === - own - return started && !ownStart + return accepted || (started && !ownStart(session, own)) } return { @@ -75,11 +78,12 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { } } }, - /** The fact may have changed: retire the offer and any failure record durably if it holds. - * Advisory: a failed write is logged, never raised. */ - recheck: (sessionId: string): void => { + /** The chat's agent proved a start: unless it is a resume action's own, the offer and any + * failure record go from the recovery file. Advisory: a failed write is logged, never raised. */ + onAgentStarted: (sessionId: string): void => { + const session = deps.sessions.get(sessionId) const capsule = deps.capsule - if (!capsule || !movedOn(sessionId)) { + if (!capsule || !session || ownStart(session, actions.get(sessionId))) { return } void deps 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 47661791c4b1..0eac87eb7e27 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 @@ -67,8 +67,8 @@ export type StructuredAgentSessionRestartResume = { }> /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ dismiss: (sessionIds?: readonly string[]) => Promise - /** A message was accepted, or the agent proved a start: the chat may have moved on. */ - recheck: (sessionId: string) => void + /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ + onAgentStarted: (sessionId: string) => void } export function createStructuredAgentSessionRestartResume( @@ -308,6 +308,6 @@ export function createStructuredAgentSessionRestartResume( // dismissal. A later capture is a new interruption and may create a fresh offer normally. dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), continueAfterRestart, - recheck: withdrawal.recheck + onAgentStarted: withdrawal.onAgentStarted } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 8e6cb0f64c90..d5d6bd0d7ab7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -74,8 +74,8 @@ export type StructuredAgentSessionResumeSetInput = { getRecord: (sessionId: string) => AgentSessionRecord | null supportsRecord: (record: AgentSessionRecord) => boolean latestPrompt: (sessionId: string) => string - /** Whether the chat moved on since the restart; false when its journal is not open here. */ - movedOn: (sessionId: string) => boolean + /** Whether the chat moved on since the offer was taken; false when its journal is not open here. */ + movedOn: (marker: AgentSessionResumeMarker) => boolean /** * Whether the lease must be free. * @@ -112,7 +112,7 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } - if (input.movedOn(marker.sessionId)) { + if (input.movedOn(marker)) { superseded.push(marker) continue } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 36090467b412..efcd70345a83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -17,6 +17,7 @@ import { structuredAgentSessionWorkingAtStop } from './structured-agent-session- import { CLAUDE_ROOT, claudeRecord, + EPOCH, HANDLE_ROOT, journal, TEARDOWN_CURRENT, @@ -64,6 +65,7 @@ describe('deriving what was working at teardown', () => { recordedAt: NOW, trigger: 'quit', teardownId: TEARDOWN_CURRENT, + journalCursor: { epoch: EPOCH, sequence: 1 }, providerHandleRoot: HANDLE_ROOT, latestUserItemId: null, activity: { state: 'working', prompts: [], tasks: [] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index e68df2c09a63..366ce1cf948e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -192,6 +192,7 @@ export function structuredAgentSessionWorkingAtStop(input: { recordedAt: input.now, trigger: input.trigger, teardownId: input.teardownId, + journalCursor: snapshot.cursor, // Root, not key: the close path advances Claude's leaf moments after this runs, and a key // comparison would then refuse the session forever. providerHandleRoot: agentSessionProviderHandleRoot(head.handle), diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts index 14b74b3e1fc2..3890bd8f81d3 100644 --- a/src/shared/agent-session-resume-marker.ts +++ b/src/shared/agent-session-resume-marker.ts @@ -19,6 +19,7 @@ import { AGENT_SESSION_RESTART_ACTIVITY_MAX_TASKS } from './agent-session-restart-activity' import type { AgentSessionRestartActivity } from './agent-session-restart-activity' +import type { AgentJournalCursor } from './agent-session-journal-types' /** Why the app went away. Recorded because an update install is a restart the user did not choose, * and the surface that offers the resume says so. */ @@ -67,6 +68,12 @@ export type AgentSessionResumeMarker = { providerHandleRoot: string /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string + /** + * Where the chat's journal stood when the offer was taken. A message accepted after it, or a + * journal on another epoch, means the chat moved on. Absent on markers from builds that did not + * record it. + */ + journalCursor?: AgentJournalCursor /** * What the session was doing, captured at the same stop-time snapshot that decided the offer. * The dialog row, the status bar and the wire candidate read ONLY this; nothing re-reads the @@ -121,6 +128,10 @@ const agentSessionResumeMarkerSchema = z.object({ trigger: z.enum(AGENT_SESSION_RESUME_TRIGGERS), providerHandleRoot: markerField, teardownId: markerField, + journalCursor: z + .object({ epoch: markerField, sequence: z.number().int().nonnegative() }) + .optional() + .catch(undefined), activity: agentSessionRestartActivitySchema.optional().catch(undefined) }) @@ -131,6 +142,10 @@ export function parseAgentSessionResumeMarker(value: unknown): AgentSessionResum if (!parsed.success) { return null } - const { activity, ...marker } = parsed.data - return activity === undefined ? marker : { ...marker, activity } + const { activity, journalCursor, ...marker } = parsed.data + return { + ...marker, + ...(journalCursor === undefined ? {} : { journalCursor }), + ...(activity === undefined ? {} : { activity }) + } } From a862d71be06ec76134a46dd3db260da3ce684e64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:44:27 -0700 Subject: [PATCH 060/101] test(native-chat): wait for the listing's retire write before reading the recovery file --- .../structured-agent-session-restart-offer-withdrawal.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 159a2ebc846e..21a2cd565cf1 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 @@ -201,7 +201,8 @@ it('keeps the offer withdrawn after the idle sweep closes the chat and it is rea await host.revealSession(SESSION) expect(await host.restartResume.list()).toEqual([]) - expect(await offersIn(root)).toEqual([]) + // The listing retires what it finds moved on; the write trails the answer. + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) }) // Resume that runs by itself at launch goes through the same call a click does, and the same rule From 0871b27d03ca43bcdbbd615df68db306b788487d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:28:47 -0700 Subject: [PATCH 061/101] fix(native-chat): keep the terminal-backed chat's read error over its local echoes Messages winning over a read error is right for the structured chat, whose read retries and whose messages came from the transcript. The terminal-backed view assembles its list from local echoes too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no error. Only the structured pane now keeps messages over an error. --- .../NativeChatStructuredSession.tsx | 2 +- .../native-chat-view-state.test.ts | 11 +++++++- .../native-chat/native-chat-view-state.ts | 25 +++++++++++++------ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index ba115db9bc43..8dac73162d01 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -104,7 +104,7 @@ export function NativeChatStructuredSession( }), [controller, props.agent, props.sessionId] ) - const viewState = selectNativeChatViewState(session) + const viewState = selectNativeChatViewState(session, { readRetries: true }) const fontScale = useNativeChatFontScale(viewState.kind === 'ready') const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts index dd0301112399..ae62b6c47023 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts @@ -42,10 +42,19 @@ describe('selectNativeChatViewState', () => { }) it('keeps a loaded transcript on screen when a later read fails (P2-21)', () => { - const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' })) + const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' }), { + readRetries: true + }) expect(state).toEqual({ kind: 'ready', isWorking: false }) }) + // The terminal-backed read does not retry; its only messages on error are local echoes (a launch + // prompt, a pending send), which must not hide the error and its way back to the terminal. + it('shows a terminal-backed read error over local echoes', () => { + const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' })) + expect(state).toEqual({ kind: 'error', message: 'boom' }) + }) + it('maps empty when there are no messages', () => { expect(selectNativeChatViewState(session({ messages: [], status: 'ready' })).kind).toBe('empty') }) diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index c3a0e348d631..25daabe268f4 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -16,17 +16,28 @@ export type NativeChatViewState = | { kind: 'ready'; isWorking: true } /** - * Decide which surface to render. Any renderable message wins, error included: a read that fails - * after the transcript loaded must not replace it with a full-pane placeholder, and the error still - * reaches the composer's error line. Messages also win over loading/empty so optimistic first sends - * never get replaced while transcript discovery catches up. + * Decide which surface to render. Any renderable message wins over loading/empty so optimistic + * first sends never get replaced while transcript discovery catches up. Where the read retries on + * its own (the structured chat), messages win over an error too: a read that fails after the + * transcript loaded keeps it, and the error reaches the composer's error line. The terminal-backed + * read does not retry, and its only messages on error are local echoes, so its error takes the pane. */ -export function selectNativeChatViewState(session: NativeChatSession): NativeChatViewState { +export function selectNativeChatViewState( + session: NativeChatSession, + { readRetries = false }: { readRetries?: boolean } = {} +): NativeChatViewState { + const error: NativeChatViewState | null = + session.status === 'error' + ? { kind: 'error', message: session.error ?? 'Conversation could not be loaded.' } + : null + if (error && !readRetries) { + return error + } if (session.messages.length > 0) { return { kind: 'ready', isWorking: session.status === 'working' } } - if (session.status === 'error') { - return { kind: 'error', message: session.error ?? 'Conversation could not be loaded.' } + if (error) { + return error } if (session.status === 'loading') { return { kind: 'loading' } From 6cde2a7b2eb5009de76274e2347adb2fd7f1ee72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:39:29 -0700 Subject: [PATCH 062/101] fix(native-chat): a start retries the exit settlement a failed journal write left owed An agent exit whose journal settlement write failed releases the lease latched until a retry lands. Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried it before the next app launch, and every send was refused. The start the send needs now runs the retry first, where the attach would. --- .../structured-agent-session-agent-start.ts | 10 +++++ .../structured-agent-session-at-rest.test.ts | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 09eb1185ceae..73c8bbfff7a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -15,6 +15,7 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { structuredAgentSessionResumeOperationId, structuredAgentSessionResumeParams @@ -81,6 +82,15 @@ async function startStructuredAgentSessionAgent( return { ok: false, refusal: unreconciled } } await context.runtimeState.resolveRecovery(sessionId) + // An exit whose journal settlement failed latches the lease until a retry lands, and the recovery + // resolver never clears that latch. The start is that retry, so the send that needs the agent + // settles it. + await retryPendingStructuredAgentSessionSettlement({ + deps: context.deps, + sessionId, + openJournal: async () => (await context.openConversation(sessionId))?.journal ?? null, + now: () => context.now() + }) const record = context.deps.store.getRecord(sessionId) if (!record) { return refuse('agent_session_identity_required', 'No structured session exists by that id.') diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 412abc8bb22e..40cd1be51c42 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -273,6 +273,51 @@ describe('an agent exit', () => { await rig.host.send(CALLER, restTestSend('again', fence)) await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) }) + + it('whose settlement write failed is settled by the next send, which is delivered', async () => { + await foundRestTestChat(rig) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private map, read for the child and its journal. + const sessions = Reflect.get(rig.host, 'sessions') as Map< + string, + { + child: { fence: number; generation: string } | null + journal: { appendLifecycleBatch: (...args: never[]) => Promise } + } + > + const open = sessions.get(SESSION)! + const running = open.child! + // A turn in flight, so the exit has something to settle. + rig.adapter.acquire.mock.calls + .at(-1)![0] + .events.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' } + ) + await rig.host.flushStreamedEvents(SESSION) + vi.spyOn(open.journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('disk full')) + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation + }) + await vi.waitFor(() => + expect(rig.store.getRecord(SESSION)?.lease.settlementRetryRequired).toBe(true) + ) + + const sent = await rig.host.send( + CALLER, + restTestSend('again', rig.store.getRecord(SESSION)!.lease.runtimeFence) + ) + + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), { + timeout: 5_000 + }) + expect(rig.store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined() + }) }) describe('every close withdraws what is queued (P2-29)', () => { From 9530e8d95489b478efe4fae9e050c7ce77d7bbb1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:41:57 -0700 Subject: [PATCH 063/101] perf(native-chat): answer the owner check without opening the chat Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the answer comes from the session record alone. Reaching it through the accessor opened each resting chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it open for the idle window. It now checks the record and the adapter's support, as before this series, and opens nothing. --- .../structured-agent-session-host.ts | 12 +++++++----- .../methods/structured-agent-session-at-rest.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) 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 c19596bb9ce7..850c5298bec7 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 @@ -294,17 +294,19 @@ export class StructuredAgentSessionHost { commands: this.deps.adapter.readCommands?.(sessionId) }) - async handoffStatus(sessionId: string): Promise { - await this.lifetime.conversation(sessionId) - // Queued behind an in-flight attach, so a starting chat answers with its settled owner. - return this.serialize(sessionId, async () => { + /** Answered from the record alone, so worktree activation asking for every chat tab opens no + * journal. Queued behind an in-flight attach, so a starting chat answers with its settled owner. */ + handoffStatus = (sessionId: string): Promise => + this.serialize(sessionId, async () => { const record = this.deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } + if (!providerSupport.adapterSupportsRecord(this.deps.adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } return structuredAgentSessionOwnerStatus(record) }) - } history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => this.backgroundTasks.history(request) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 40cd1be51c42..e356cea21e7d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -122,6 +122,16 @@ describe('opening a chat at rest (P2-01)', () => { expect(held).toMatchObject({ ok: true, result: { held: true } }) }) + // Worktree activation asks this for every chat tab in the worktree. + it('answers the owner check from the record without opening the chat', async () => { + await restingChat() + + const [status] = await call('agentSession.handoffStatus', { sessionId: SESSION }) + + expect(status).toMatchObject({ ok: true, result: { owner: expect.any(String) } }) + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + it('starts the agent on the first send (P2-01)', async () => { await restingChat() const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence From 2f39e1b1dbd7d21a4f714db29a803437c38958f7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:48:49 -0700 Subject: [PATCH 064/101] fix(native-chat): a read waiting on the session lock opens nothing once quit began The accessor checked for quit before queueing the open, so a read queued behind a session task ran its open after teardown had begun and indexed a journal no teardown step would close. The check now runs at the open itself. --- ...red-agent-session-conversation-lifetime.ts | 7 +++--- .../structured-agent-session-at-rest.test.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index a71fa103cbb8..a59b72b91954 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -96,9 +96,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { if (open) { return open } - if (disposed) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) - } const record = deps().store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') @@ -107,6 +104,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { throw new Error('structured_agent_session_unsupported') } return serialize(sessionId, async () => { + // Read at the open itself: a read queued before quit began runs after it. + if (disposed) { + throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + } const session = await host.open(sessionId) if (!session) { throw new Error('agent_session_identity_required') diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index e356cea21e7d..8c10c7130172 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -175,6 +175,31 @@ describe('the accessor', () => { expect(rig.adapter.acquire).not.toHaveBeenCalled() }) + it('opens nothing once quit began, for a read that was already waiting on the lock', async () => { + await restingChat() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private per-session queue, held so the read waits behind it. + const serialize = Reflect.get(rig.host, 'serialize') as ( + sessionId: string, + task: () => Promise + ) => Promise + let release = (): void => undefined + const held = new Promise((started) => { + void serialize(SESSION, () => { + started() + return new Promise((resolve) => (release = resolve)) + }) + }) + await held + const read = rig.host.history({ sessionId: SESSION, direction: 'tail' }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private lifetime, disposed as quit's first teardown step does. + ;(Reflect.get(rig.host, 'lifetime') as { dispose: () => void }).dispose() + release() + + await expect(read).rejects.toThrow() + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() From 4ad06ca0e0846d9c48c6bcddd9fb704eb659abfe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:52:08 -0700 Subject: [PATCH 065/101] fix(native-chat): read a failed resume's chat before calling it retryable Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the restart, read from its journal. The failure list read it only for a chat already open, so once the idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did nothing. The list now opens the failed chats first, as the offer list does. --- ...ured-agent-session-restart-failure-ledger.ts | 9 +++++++-- ...ent-session-restart-offer-withdrawal.test.ts | 17 +++++++++++++++++ ...uctured-agent-session-restart-resume-host.ts | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) 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 be20c3ef0d3a..ed7e508307a5 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 @@ -77,6 +77,8 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ retryable: (marker: AgentSessionResumeMarker) => boolean + /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ + reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -119,8 +121,11 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { ] } - const list = async (): Promise => - (await read()).flatMap(toRow) + const list = async (): Promise => { + const failures = await read() + await deps.reveal(failures.map((failure) => failure.marker)) + return failures.flatMap(toRow) + } const settle: StructuredAgentSessionRestartFailureLedger['settle'] = async ( operationId, 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 21a2cd565cf1..996862a7ea21 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 @@ -205,6 +205,23 @@ 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([])) }) +// A failure the chat moved on from is read from its journal like an offer is, open or not. +it('keeps a failure not retryable after the user moved on and the sweep closed the chat', async () => { + const state = await offered() + const { host, clock } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(resumed.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + await sendWhoseStartFails(state) + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) + clock.now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + + await sweepOnce(host) + expect(host.hasSession(SESSION)).toBe(false) + + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) +}) + // Resume that runs by itself at launch goes through the same call a click does, and the same rule // decides: whichever of the two was accepted first since the restart wins. it("refuses an automatic continuation when the user's message was accepted first, and writes nothing", async () => { 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 0eac87eb7e27..b32935060358 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 @@ -114,6 +114,7 @@ export function createStructuredAgentSessionRestartResume( getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, + reveal: (markers) => revealMarkers(markers), now: surfaces.now, enqueue: enqueueRecoveryOperation }) From 1aaf776ce1733b31ee8ec821e00900dc022dd178 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:53:49 -0700 Subject: [PATCH 066/101] test(native-chat): type the provider event sink the settlement test reaches for --- .../rpc/methods/structured-agent-session-at-rest.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 8c10c7130172..3e79121bfe2b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -323,8 +323,8 @@ describe('an agent exit', () => { const running = open.child! // A turn in flight, so the exit has something to settle. rig.adapter.acquire.mock.calls - .at(-1)![0] - .events.appendItem( + .at(-1)?.[0] + .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, { kind: 'turn', turnId: 'working', state: 'running' } ) From 9a5e5e6af63c4169b9af7da937d5eeb3afea5596 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:03:12 -0700 Subject: [PATCH 067/101] fix(native-chat): refuse a read of an unusable journal file with the final unreadable code A chat whose journal.db is not a database threw the raw SQLite message on every read, and the pane re-asked every 750 ms forever. The accessor now maps an unusable-database failure to agent_session_journal_unreadable (any other failure stays retryable), and the read transport stops reconnecting on that read refusal. --- ...red-agent-session-conversation-lifetime.ts | 8 +++- ...d-agent-session-unreadable-journal.test.ts | 42 +++++++++++++++++++ src/main/sqlite/sqlite-read-failure.test.ts | 26 +++++++++++- src/main/sqlite/sqlite-read-failure.ts | 23 ++++++++++ ...tured-agent-session-read-transport.test.ts | 19 +++++++++ ...structured-agent-session-read-transport.ts | 6 ++- .../structured-agent-session-read-refusal.ts | 34 +++++++++++---- 7 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unreadable-journal.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index a71fa103cbb8..5aa74aa0f639 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -17,6 +17,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { isUnusableSqliteDatabaseError } from '../../sqlite/sqlite-read-failure' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -107,7 +108,12 @@ export function createStructuredAgentSessionConversationLifetime(host: { throw new Error('structured_agent_session_unsupported') } return serialize(sessionId, async () => { - const session = await host.open(sessionId) + const session = await host.open(sessionId).catch((error: unknown) => { + // Final: the same file answers the same on every retry. Anything else stays retryable. + throw isUnusableSqliteDatabaseError(error) + ? new Error('agent_session_journal_unreadable') + : error + }) if (!session) { throw new Error('agent_session_identity_required') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unreadable-journal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unreadable-journal.test.ts new file mode 100644 index 000000000000..bedecd17732d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unreadable-journal.test.ts @@ -0,0 +1,42 @@ +// A chat whose journal file is not a usable database: its reads refuse with the one code a client +// treats as final, instead of a raw SQLite message the pane would re-ask forever. + +import { writeFile } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_SESSION as SESSION, + type RestTestRig +} from './structured-agent-session-rest-test-rig' + +let rig: RestTestRig | null = null + +afterEach(async () => { + await rig?.dispose() + rig = null +}) + +async function corruptJournal(active: RestTestRig): Promise { + const dir = journalDirectoryFor(active.root, { workspaceId: 'workspace-1', sessionId: SESSION }) + await writeFile(journalDatabaseFile(dir), Buffer.alloc(8192, 7)) +} + +describe('an unloadable chat journal', () => { + it('refuses a history read with agent_session_journal_unreadable on every ask', async () => { + const active = await createRestTestRig() + rig = active + await foundRestTestChat(active) + await active.restart() + await corruptJournal(active) + const restarted = await active.restart() + + await expect(restarted.history({ sessionId: SESSION, direction: 'tail' })).rejects.toThrow( + /^agent_session_journal_unreadable$/ + ) + await expect(restarted.history({ sessionId: SESSION, direction: 'tail' })).rejects.toThrow( + /^agent_session_journal_unreadable$/ + ) + }) +}) diff --git a/src/main/sqlite/sqlite-read-failure.test.ts b/src/main/sqlite/sqlite-read-failure.test.ts index 8bd43d54eaa4..553788063c21 100644 --- a/src/main/sqlite/sqlite-read-failure.test.ts +++ b/src/main/sqlite/sqlite-read-failure.test.ts @@ -1,9 +1,13 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import SyncDatabase from './sync-database' -import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite-read-failure' +import { + classifySqliteReadFailure, + isTransientSqliteContention, + isUnusableSqliteDatabaseError +} from './sqlite-read-failure' // Error codes here are the ones a real node:sqlite open produces: a contended // database reports errcode 5 ("database is locked"), while a read-only WAL open @@ -111,3 +115,21 @@ describe('classifySqliteReadFailure', () => { ).toBe('unreadable') }) }) + +describe('isUnusableSqliteDatabaseError', () => { + it('recognizes the error a real open of a garbage file throws, and nothing transient', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-sqlite-unusable-')) + tempDirs.push(dir) + const path = join(dir, 'garbage.db') + writeFileSync(path, Buffer.alloc(4096, 7)) + let thrown: unknown + try { + new SyncDatabase(path).pragma('user_version', { simple: true }) + } catch (error) { + thrown = error + } + expect(isUnusableSqliteDatabaseError(thrown)).toBe(true) + expect(isUnusableSqliteDatabaseError(new Error('database is locked'))).toBe(false) + expect(isUnusableSqliteDatabaseError({ errcode: 14, message: 'unable to open' })).toBe(false) + }) +}) diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts index f96176eee0ac..f72ff3087965 100644 --- a/src/main/sqlite/sqlite-read-failure.ts +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -6,7 +6,9 @@ /** Primary result codes; extended codes pack the primary code in the low byte. */ const SQLITE_BUSY = 5 const SQLITE_LOCKED = 6 +const SQLITE_CORRUPT = 11 const SQLITE_CANTOPEN = 14 +const SQLITE_NOTADB = 26 // Shared with the Codex index-heal pass, which only ever sees a relayed message // string (app-server RPC drops `errcode`), so message matching is not optional. @@ -24,6 +26,27 @@ function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error) } +// SQLite reports a torn file at the first statement that reads a page, so this matches the message +// as well as the code. +const UNUSABLE_DATABASE = + /SQLITE_CORRUPT|SQLITE_NOTADB|file is not a database|database disk image is malformed/i + +/** True when the file is not a usable database at all, which no retry can change. */ +export function isUnusableSqliteDatabaseError(error: unknown): boolean { + const errcode = primaryErrcode(error) + if (errcode === SQLITE_CORRUPT || errcode === SQLITE_NOTADB) { + return true + } + if (!(error instanceof Error)) { + return false + } + const code = 'code' in error ? error.code : undefined + return ( + (typeof code === 'string' && UNUSABLE_DATABASE.test(code)) || + UNUSABLE_DATABASE.test(error.message) + ) +} + /** * True when a SQLite failure means "someone else holds the database right now". * @param error - The thrown value from a `node:sqlite` call or a relayed message. diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 0305b93cb6d4..29b0a80b46be 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -335,6 +335,25 @@ describe('structured agent-session read transport unattached refusals', () => { } }) + it('stops re-asking a history whose journal file is not a usable database', async () => { + vi.useFakeTimers() + try { + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + // The host raises it with the bare code as the message; the RPC layer's code is generic. + attempts[0].onError({ code: 'runtime_error', message: 'agent_session_journal_unreadable' }) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenCalledExactlyOnceWith('agent_session_journal_unreadable') + await vi.advanceTimersByTimeAsync(10_000) + expect(attempts).toHaveLength(1) + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + it('restarts the grace once a read lands, so a later refusal is transitional again', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index bc1f77b33999..64d29dad7c01 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -3,6 +3,7 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-sessio import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' import { AGENT_SESSION_UNATTACHED_READ_GRACE_MS, + isFinalAgentSessionReadRefusal, isUnattachedAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal' import { agentSessionErrorText } from '../../../../shared/agent-session-error-text' @@ -45,6 +46,8 @@ export function startStructuredAgentSessionReadTransport(args: { dispose: () => void } { let stopped = false + // An unusable journal file answers the same on every ask, so nothing re-asks it. + let finalRefusal = false let connected = false let unattachedSince: number | null = null let opening = false @@ -58,7 +61,7 @@ export function startStructuredAgentSessionReadTransport(args: { } }) const reconnectScheduler = createReconnectScheduler({ - shouldStop: () => stopped || connected, + shouldStop: () => stopped || connected || finalRefusal, reconnect: () => void open() }) const isCurrentOpenGeneration = (candidate: number): boolean => @@ -78,6 +81,7 @@ export function startStructuredAgentSessionReadTransport(args: { * transitional, so the pane is owed the failure rather than a spinner that never resolves. */ const reportReadFailure = (error: unknown): void => { + finalRefusal = isFinalAgentSessionReadRefusal(error) if (!isUnattachedAgentSessionReadRefusal(error)) { clearUnattachedReadGrace() args.applyError(agentSessionErrorText(error)) diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts index 18487f12be38..b604c98113a0 100644 --- a/src/shared/structured-agent-session-read-refusal.ts +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -24,6 +24,19 @@ export const AGENT_SESSION_UNATTACHED_REFUSAL_CODE = 'agent_session_ownership_un */ export const AGENT_SESSION_UNATTACHED_READ_GRACE_MS = 5_000 +/** Raised for a chat whose journal file is not a usable database. */ +export const AGENT_SESSION_JOURNAL_UNREADABLE_REFUSAL_CODE = 'agent_session_journal_unreadable' + +function isReadRefusal(error: unknown, code: string): boolean { + if (typeof error === 'string') { + return error === code + } + if (typeof error !== 'object' || error === null) { + return false + } + return ('code' in error && error.code === code) || ('message' in error && error.message === code) +} + /** * Whether a read failure is that refusal. * @@ -31,15 +44,22 @@ export const AGENT_SESSION_UNATTACHED_READ_GRACE_MS = 5_000 * bare refusal code, and the raw failure payload a stream delivers to its error callback. */ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { + return isReadRefusal(error, AGENT_SESSION_UNATTACHED_REFUSAL_CODE) +} + +/** + * A read refusal no retry can change: re-asking reads the same unusable file. Matched on the + * message, which a read raises as the bare code: a send's refusal under the same code carries its + * cause as the message instead, and that cause can clear. + */ +export function isFinalAgentSessionReadRefusal(error: unknown): boolean { if (typeof error === 'string') { - return error === AGENT_SESSION_UNATTACHED_REFUSAL_CODE - } - if (typeof error !== 'object' || error === null) { - return false + return error === AGENT_SESSION_JOURNAL_UNREADABLE_REFUSAL_CODE } - const { code, message } = error as { code?: unknown; message?: unknown } return ( - code === AGENT_SESSION_UNATTACHED_REFUSAL_CODE || - message === AGENT_SESSION_UNATTACHED_REFUSAL_CODE + typeof error === 'object' && + error !== null && + 'message' in error && + error.message === AGENT_SESSION_JOURNAL_UNREADABLE_REFUSAL_CODE ) } From daaf0cf9a606c579e508624021c0deb101473d0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:05:20 -0700 Subject: [PATCH 068/101] fix(native-chat): say the structured read keeps trying only where it does The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an untranslated fallback whenever the read error had no text, and the empty state prefers any message. The view state now leaves the message out, so the structured pane shows that line and the terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an error frame, so it no longer makes the claim. --- mobile/src/session/MobileNativeChatView.tsx | 2 +- mobile/src/session/mobile-native-chat-render-data.test.ts | 3 --- mobile/src/session/mobile-native-chat-render-data.ts | 6 ++---- .../components/native-chat/native-chat-view-state.test.ts | 7 +++++++ .../src/components/native-chat/native-chat-view-state.ts | 5 +++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 0043ba3ae1a7..6a51601d151a 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -287,7 +287,7 @@ export function MobileNativeChatView({ [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] ) - const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error, structuredActivityUi) + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 6dd77f8a1b92..3c0281e24b88 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -50,9 +50,6 @@ describe('mobileNativeChatEmptyState', () => { expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( 'The transcript could not be read. Toggle back to the terminal to keep working.' ) - expect(mobileNativeChatEmptyState('error', 'claude', undefined, true)?.subtitle).toBe( - 'The transcript could not be read. Orca keeps trying to load it.' - ) }) it('returns null for states that show no empty copy', () => { diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 43e31c5be19b..da93efadbb87 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -19,9 +19,7 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' export function mobileNativeChatEmptyState( status: MobileNativeChatStatus, agent: string | null, - error?: string, - /** The structured lane, whose read retries on its own. */ - retrying = false + error?: string ): NativeChatEmptyStateCopy | null { const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' switch (status) { @@ -33,7 +31,7 @@ export function mobileNativeChatEmptyState( case 'ready': return formatNativeChatEmptyStateCopy('empty', agentLabel) case 'error': { - const copy = formatNativeChatEmptyStateCopy(retrying ? 'retryingError' : 'error', agentLabel) + const copy = formatNativeChatEmptyStateCopy('error', agentLabel) return error ? { ...copy, subtitle: error } : copy } default: diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts index ae62b6c47023..39bf20322b50 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts @@ -41,6 +41,13 @@ describe('selectNativeChatViewState', () => { expect(state).toEqual({ kind: 'error', message: 'boom' }) }) + // The pane's own translated line fills in, which for the structured chat says the read retries. + it('maps an error with no text to no message', () => { + expect(selectNativeChatViewState(session({ messages: [], status: 'error' }))).toEqual({ + kind: 'error' + }) + }) + it('keeps a loaded transcript on screen when a later read fails (P2-21)', () => { const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' }), { readRetries: true diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index 25daabe268f4..13a7e8a5afe6 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -10,7 +10,7 @@ import type { NativeChatSession } from '../../../../shared/native-chat-types' * live in-flight indicator. The rest are full-pane states. */ export type NativeChatViewState = | { kind: 'loading' } - | { kind: 'error'; message: string } + | { kind: 'error'; message?: string } | { kind: 'empty' } | { kind: 'ready'; isWorking: false } | { kind: 'ready'; isWorking: true } @@ -26,9 +26,10 @@ export function selectNativeChatViewState( session: NativeChatSession, { readRetries = false }: { readRetries?: boolean } = {} ): NativeChatViewState { + // No text of its own: the empty state supplies the pane's translated line. const error: NativeChatViewState | null = session.status === 'error' - ? { kind: 'error', message: session.error ?? 'Conversation could not be loaded.' } + ? { kind: 'error', ...(session.error ? { message: session.error } : {}) } : null if (error && !readRetries) { return error From 42307c867eecde7afa6a3444060d2e06d11189d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:07:12 -0700 Subject: [PATCH 069/101] test(native-chat): await the send's settlement instead of polling for the start The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a loaded machine outran. They now await the host's own settlement of the message. --- .../structured-agent-session-at-rest.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 3e79121bfe2b..9730f08f1633 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -136,9 +136,13 @@ describe('opening a chat at rest (P2-01)', () => { await restingChat() const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence const sent = await rig.host.send(CALLER, restTestSend('wake up', fence)) - expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + // Awaits the provider's answer itself rather than polling for it, however slow the start. + await rig.host.waitForSendSettlement(SESSION, sent.value.clientMessageId) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.dispatch).toHaveBeenCalledOnce() }) }) @@ -347,10 +351,11 @@ describe('an agent exit', () => { restTestSend('again', rig.store.getRecord(SESSION)!.lease.runtimeFence) ) - expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), { - timeout: 5_000 - }) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await rig.host.waitForSendSettlement(SESSION, sent.value.clientMessageId) + expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2) expect(rig.store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined() }) }) From 9d9dd98953db8748fb54410ead18977a670465e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:19:42 -0700 Subject: [PATCH 070/101] fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now dated by the resume action. Telling a rejected continuation from the user's own message read the operation ledger, whose rows expire after about a day; after that a failed resume stopped being retryable. The offer now records the continuation each action sends on its own capsule entry, bounded to the newest 16, so the ids end with the offer. The ledger read is deleted. --- ...t-session-restart-continuation-envelope.ts | 12 +++--- ...ured-agent-session-restart-continuation.ts | 9 ++-- ...t-session-restart-offer-withdrawal.test.ts | 41 ++++++++++++++++++- ...-agent-session-restart-offer-withdrawal.ts | 7 ++-- ...tured-agent-session-restart-resume-host.ts | 31 ++++++-------- .../agent-session-recovery-capsule-entries.ts | 14 +++++++ .../agent-session-recovery-capsule.test.ts | 30 ++++++++++++++ .../runtime/agent-session-recovery-capsule.ts | 10 ++++- src/shared/agent-session-resume-marker.ts | 17 +++++++- ...ssion-resume-marker-downgrade.unit.test.ts | 9 +++- 10 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts index 56a071eb9253..827a916048a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts @@ -18,13 +18,15 @@ export function restartContinuationBody(marker: AgentSessionResumeMarker): Agent } /** One resume action's continuation: the same action sends it once, and a retry is a new action - * with a new message, never a replay of the one that failed. */ + * with a new message, never a replay of the one that failed. Dated by the action, not the quit: + * the ledger refuses a new id dated more than a day back, and an offer has no expiry. */ export function restartContinuationId( sessionId: string, marker: AgentSessionResumeMarker, - operationId: string + operationId: string, + actionAt: number ): string { - return `${marker.recordedAt.toString().padStart(13, '0')}-${createHash('sha256') + return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${createHash('sha256') .update( JSON.stringify([ marker.teardownId, @@ -44,14 +46,14 @@ export function restartContinuationEnvelope( sessionId: string, fence: number, marker: AgentSessionResumeMarker, - operationId: string + continuationId: string ): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { const body = restartContinuationBody(marker) return { body, envelope: { sessionId, - clientOperationId: restartContinuationId(sessionId, marker, operationId), + clientOperationId: continuationId, expectedRuntimeFence: fence, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 4277704131eb..e96f2b77c7f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -182,11 +182,12 @@ export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, marker: AgentSessionResumeMarker, - operationId: string + /** This action's continuation, as its offer recorded it. */ + continuationId: string ): Promise { let started: StartedStructuredAgentSessionContinuation try { - started = await sendContinuation(deps, sessionId, marker, operationId) + started = await sendContinuation(deps, sessionId, marker, continuationId) } catch (error) { // The user's own message came first: nothing failed, so the chat says nothing. if (!(error instanceof RestartContinuationSupersededError)) { @@ -260,13 +261,13 @@ async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, marker: AgentSessionResumeMarker, - operationId: string + continuationId: string ): Promise { const fence = deps.currentFence(sessionId) if (fence === null) { return { done: { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } } } - const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker, operationId) + const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker, continuationId) const sent = await deps.send({ envelope, body }).catch((error: unknown) => { if (error instanceof AgentSessionPreDispatchError) { throw error 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 996862a7ea21..8c601155bbf1 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 @@ -222,6 +222,43 @@ it('keeps a failure not retryable after the user moved on and the sweep closed t expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) }) +const DAY_AND_AN_HOUR = 25 * 60 * 60 * 1000 + +// An offer has no expiry; the ledger refuses a new id dated more than a day back. +it('resumes more than a day after the quit', async () => { + const state = await offered() + const { host, clock, dispatch } = state + clock.now += DAY_AND_AN_HOUR + + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(resumed.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) + expect(sentTexts(dispatch)).toHaveLength(1) + expect(await host.restartResume.listFailures()).toEqual([]) +}) + +// The offer remembers its own continuations; nothing about them expires before the offer does. +it('keeps a failed resume retryable more than a day later, after the ledger pruned its row', async () => { + const state = await offered() + const { host, clock, store } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + clock.now += DAY_AND_AN_HOUR + // What an app restart does to the operation ledger a day on. + await store.reconcileOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reconciliation only reads the probe outcome. + probe: async () => ({ outcome: 'pid-absent' }) as never, + now: clock.now + }) + + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + // Resume that runs by itself at launch goes through the same call a click does, and the same rule // decides: whichever of the two was accepted first since the restart wins. it("refuses an automatic continuation when the user's message was accepted first, and writes nothing", async () => { @@ -308,7 +345,6 @@ describe('reading the chat against where the offer was taken', () => { const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. sessions: new Map([[SESSION, session as never]]), - isContinuation: (id) => input.continuations?.includes(id) === true, now: () => NOW, enqueue: (operation) => operation() }) @@ -319,7 +355,8 @@ describe('reading the chat against where the offer was taken', () => { trigger: 'quit', providerHandleRoot: 'codex:"thread"', teardownId: 'teardown-1', - ...(journalCursor ? { journalCursor } : {}) + ...(journalCursor ? { journalCursor } : {}), + ...(input.continuations ? { continuations: input.continuations } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 67eafcd1effd..3288a45308b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -24,8 +24,6 @@ export type StructuredAgentSessionRestartOfferSession = Pick< export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { sessions: ReadonlyMap capsule?: Pick - /** Whether this message was a restart continuation, by who sent it. */ - isContinuation: (clientMessageId: string) => boolean now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -54,10 +52,11 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { (submission) => submission.clientMessageId !== own && (submission.acceptedSequence ?? 0) > taken.sequence && - // A continuation that was rejected never reached the agent: a retry sends a new one. + // A continuation the offer sent that was rejected never reached the agent: a retry + // sends a new one. !( submission.dispatchState === 'rejected' && - deps.isContinuation(submission.clientMessageId) + marker.continuations?.includes(submission.clientMessageId) ) )) // Proven, not merely spawned: a start that failed during startup never ran the agent. 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 b32935060358..6d1d4aa1be61 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 @@ -37,10 +37,7 @@ import { createStructuredAgentSessionRestartOfferWithdrawal, type StructuredAgentSessionRestartOfferSession } from './structured-agent-session-restart-offer-withdrawal' -import { - STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, - type StructuredAgentSessionRestartResumeSurfaces -} from './structured-agent-session-restart-resume-wiring' +import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -95,11 +92,6 @@ export function createStructuredAgentSessionRestartResume( const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ sessions, ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), - isContinuation: (clientMessageId) => - deps.store.getOperationRow( - STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, - clientMessageId - ) !== null, now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -156,7 +148,7 @@ export function createStructuredAgentSessionRestartResume( const run = async ( sessionIds: readonly string[] | undefined, owner: string, - continueOne: (marker: AgentSessionResumeMarker, operationId: string) => Promise + continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. @@ -171,22 +163,25 @@ export function createStructuredAgentSessionRestartResume( return null } const operationId = randomUUID() + const actionAt = surfaces.now() + // Recorded on the offer as it is reserved, so a rejected one is told apart for as long as the + // offer lasts. + const continuationFor = (marker: AgentSessionResumeMarker) => + restartContinuationId(marker.sessionId, marker, operationId, actionAt) const reserved = (await enqueueRecoveryOperation( () => deps.recoveryCapsule?.beginResume( eligible.map((candidate) => candidate.sessionId), operationId, - surfaces.now() + actionAt, + continuationFor ) ?? Promise.resolve([]) )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates const releases = reserved.map((marker) => - withdrawal.begin( - marker.sessionId, - restartContinuationId(marker.sessionId, marker, operationId) - ) + withdrawal.begin(marker.sessionId, continuationFor(marker)) ) try { const outcomes = await resumeStructuredAgentSessionsFromRestart( @@ -199,7 +194,7 @@ export function createStructuredAgentSessionRestartResume( resume: async (sessionId) => { const marker = markersBySession.get(sessionId) if (marker) { - await continueOne(marker, operationId) + await continueOne(marker, continuationFor(marker)) } } }, @@ -235,12 +230,12 @@ export function createStructuredAgentSessionRestartResume( const verdicts: Promise[] = [] // A chat holds its slot until its agent took the continuation or its start failed, so a batch // never starts more agents at once than the runner allows; the provider's answer comes after. - const action = await run(sessionIds, owner, async (marker, operationId) => { + const action = await run(sessionIds, owner, async (marker, continuationId) => { const started = await startStructuredAgentSessionContinuation( restartContinuationDeps(continuationHost, marker), marker.sessionId, marker, - operationId + continuationId ) if ('done' in started) { continued.push(started.done) diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index de8a452c87a2..6f821b5b2dc3 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AGENT_SESSION_RESUME_FAILURE_OUTCOMES, + AGENT_SESSION_RESUME_MAX_CONTINUATIONS, parseAgentSessionResumeMarker, type AgentSessionResumeFailureOutcome, type AgentSessionResumeMarker @@ -181,6 +182,19 @@ export function normalizeState( return { entries: [...bySession.values()], failed } } +/** The offer with one more continuation sent for it, keeping the newest the marker is bounded to. */ +export function markerWithContinuation( + marker: AgentSessionResumeMarker, + continuationId: string +): AgentSessionResumeMarker { + return { + ...marker, + continuations: [...(marker.continuations ?? []), continuationId].slice( + -AGENT_SESSION_RESUME_MAX_CONTINUATIONS + ) + } +} + export function shouldReplaceMarker( current: AgentSessionResumeMarker, incoming: AgentSessionResumeMarker diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 2150e5c7d516..120a2e28484b 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -9,6 +9,7 @@ import { NOW, SESSION } from '../native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness' +import { AGENT_SESSION_RESUME_MAX_CONTINUATIONS } from '../../shared/agent-session-resume-marker' import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE, @@ -186,6 +187,35 @@ describe('durable restart offers', () => { expect(await capsule.listFailed(later)).toHaveLength(1) }) + it("records each action's continuation on the offer it reserves and files it with the failure", async () => { + await capsule.record([marker()], NOW) + const continuationFor = (operationId: string) => () => `continuation-${operationId}` + + expect( + await capsule.beginResume([SESSION], 'operation-a', NOW, continuationFor('operation-a')) + ).toEqual([marker({ continuations: ['continuation-operation-a'] })]) + await capsule.failResume('operation-a', [failure()], NOW) + await capsule.beginResume([SESSION], 'operation-b', NOW + 1, continuationFor('operation-b')) + await capsule.failResume('operation-b', [failure({ failedAt: NOW + 1 })], NOW + 1) + + expect((await capsule.listFailed(NOW + 1))[0]?.marker.continuations).toEqual([ + 'continuation-operation-a', + 'continuation-operation-b' + ]) + }) + + it('keeps only the newest continuations an offer is bounded to', async () => { + await capsule.record([marker()], NOW) + for (let attempt = 0; attempt <= AGENT_SESSION_RESUME_MAX_CONTINUATIONS; attempt += 1) { + await capsule.beginResume([SESSION], `operation-${attempt}`, NOW, () => `c-${attempt}`) + await capsule.rollbackResume(`operation-${attempt}`, NOW) + } + + const [offer] = await capsule.list(NOW) + expect(offer?.continuations).toHaveLength(AGENT_SESSION_RESUME_MAX_CONTINUATIONS) + expect(offer?.continuations?.at(-1)).toBe(`c-${AGENT_SESSION_RESUME_MAX_CONTINUATIONS}`) + }) + it('refiles a failed retry with its new reason', async () => { await capsule.record([marker()], NOW) await fileFailure() diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 8b54871b9f4a..27f6543f48ca 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -12,6 +12,7 @@ import { import { withFileTransactionLock } from '../file-transaction-lock' import { MAX_FAILURE_FIELD_LENGTH, + markerWithContinuation, normalizeState, parseState, shouldReplaceMarker, @@ -103,14 +104,19 @@ export class AgentSessionRecoveryCapsule { beginResume( sessionIds: readonly string[] | undefined, operationId: string, - now: number + now: number, + /** The continuation this action sends for a marker, recorded on the offer it reserves. */ + continuationFor?: (marker: AgentSessionResumeMarker) => string ): Promise { return withFileTransactionLock(this.filePath, async () => { const state = await this.readState() const { entries, failed } = normalizeState(state, now) const requested = sessionIds === undefined ? null : new Set(sessionIds) const selected: AgentSessionResumeMarker[] = [] - const reserve = (marker: AgentSessionResumeMarker): RecoveryEntry => { + const reserve = (offer: AgentSessionResumeMarker): RecoveryEntry => { + const marker = continuationFor + ? markerWithContinuation(offer, continuationFor(offer)) + : offer selected.push(marker) return { state: 'in-progress', operationId, startedAt: now, marker } } diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts index 3890bd8f81d3..a8827339aa1e 100644 --- a/src/shared/agent-session-resume-marker.ts +++ b/src/shared/agent-session-resume-marker.ts @@ -74,6 +74,12 @@ export type AgentSessionResumeMarker = { * record it. */ journalCursor?: AgentJournalCursor + /** + * The client message ids of the continuations this offer's resume actions sent, newest last. + * A rejected one never reached the agent, so it is not the chat moving on and a retry still + * runs. On the offer itself, so the ids end with it. Absent until an action runs. + */ + continuations?: string[] /** * What the session was doing, captured at the same stop-time snapshot that decided the offer. * The dialog row, the status bar and the wire candidate read ONLY this; nothing re-reads the @@ -91,6 +97,9 @@ const MAX_FIELD_LENGTH = 512 /** Bounded because a marker is read back from a file this process did not necessarily write. */ const markerField = z.string().min(1).max(MAX_FIELD_LENGTH) +/** Retries an offer remembers; an older rejected one past this counts as the chat moving on. */ +export const AGENT_SESSION_RESUME_MAX_CONTINUATIONS = 16 + const agentSessionResumeWorkSchema = z.object({ kind: z.enum(['turn', 'submission']), id: markerField @@ -132,6 +141,11 @@ const agentSessionResumeMarkerSchema = z.object({ .object({ epoch: markerField, sequence: z.number().int().nonnegative() }) .optional() .catch(undefined), + continuations: z + .array(markerField) + .max(AGENT_SESSION_RESUME_MAX_CONTINUATIONS) + .optional() + .catch(undefined), activity: agentSessionRestartActivitySchema.optional().catch(undefined) }) @@ -142,10 +156,11 @@ export function parseAgentSessionResumeMarker(value: unknown): AgentSessionResum if (!parsed.success) { return null } - const { activity, journalCursor, ...marker } = parsed.data + const { activity, journalCursor, continuations, ...marker } = parsed.data return { ...marker, ...(journalCursor === undefined ? {} : { journalCursor }), + ...(continuations === undefined ? {} : { continuations }), ...(activity === undefined ? {} : { activity }) } } diff --git a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts index f7f23d528aaf..0d820fc3b7a9 100644 --- a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts @@ -17,7 +17,7 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // A release whose marker parser still requires the chat's newest message id. const BASELINE_REF = 'v1.4.211' -test('an older build reads the restart offer this build records at quit', async () => { +test('an older build reads the restart offer this build records, continuations and all', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-resume-marker-downgrade-')) try { // This build: the offer teardown takes for a chat whose turn was running. @@ -33,7 +33,12 @@ test('an older build reads the restart offer this build records at quit', async if (!marker) { throw new Error('this build recorded no offer for a running turn') } - await new AgentSessionRecoveryCapsule(directory).record([marker], NOW) + const capsule = new AgentSessionRecoveryCapsule(directory) + await capsule.record([marker], NOW) + // A resume action that did not finish leaves its continuation recorded on the offer. + await capsule.beginResume([SESSION], 'operation-1', NOW, () => 'continuation-1') + await capsule.rollbackResume('operation-1', NOW) + expect(await capsule.list(NOW)).toMatchObject([{ continuations: ['continuation-1'] }]) // The older build, after a downgrade, still lists it. const checkout = await materializeReleaseCheckout(BASELINE_REF) From bab2cef5854f4b0f1197c6104da46527f1c5d4f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:26:10 -0700 Subject: [PATCH 071/101] fix(orchestration): route no mail to a structured worker its orchestration released A structured worker is routed on ownership, and a resting worker's lease is released, so ownership held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one restarted its agent. Routing now also reads the orchestration's own resource row: once it is released, direct mail, group addressing and worker-show's addressable answer drop the worker, as they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored. --- .../structured-worker-group-addressing.ts | 20 +++++++--- .../worker/worker-observation.ts | 8 +++- .../runtime/structured-worker-at-rest.test.ts | 38 +++++++++++++++++++ .../runtime/structured-worker-authority.ts | 18 ++++++++- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 5eb1157973fd..dd237a7e4991 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -16,7 +16,11 @@ */ import type { TuiAgent } from '../../../shared/tui-agent' -import { structuredWorkerAgent, structuredWorkerOwned } from '../structured-worker-authority' +import { + structuredWorkerAgent, + structuredWorkerOwned, + structuredWorkerResourceReleased +} from '../structured-worker-authority' import { STRUCTURED_WORKER_INCARNATION_PREFIX, structuredWorkerIdentityFromRow @@ -36,14 +40,17 @@ export type OrchestrationAddressableAgent = { * Structured workers this runtime owns, as group-address candidates. * * Read from the durable worker-terminal rows, which outlive a settled dispatch and a restart, and - * gated on ownership — the same answer direct mail routes on — never on liveness: a worker at rest - * is a recipient, and the mail starts it. A retired one is not. + * gated on ownership and on the orchestration not having released it — the same answer direct mail + * routes on — never on liveness: a worker at rest is a recipient, and the mail starts it. A retired + * or released one is not. */ export function listAddressableStructuredWorkers( db: OrchestrationDb | null ): OrchestrationAddressableAgent[] { const seen = new Set() - return (db?.listWorkerTerminalResourcesByIncarnationPrefix(STRUCTURED_WORKER_INCARNATION_PREFIX) ?? []) + return ( + db?.listWorkerTerminalResourcesByIncarnationPrefix(STRUCTURED_WORKER_INCARNATION_PREFIX) ?? [] + ) .flatMap((row) => { const identity = structuredWorkerIdentityFromRow(row) // Newest row first: a session is one recipient, under its latest handle. @@ -51,7 +58,10 @@ export function listAddressableStructuredWorkers( return [] } seen.add(identity.sessionId) - return structuredWorkerOwned(identity.sessionId) === true ? [identity] : [] + return structuredWorkerOwned(identity.sessionId) === true && + !structuredWorkerResourceReleased(row) + ? [identity] + : [] }) .map((identity) => ({ handle: identity.handle, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index 6f654c358172..3448bd2c96d8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -10,6 +10,7 @@ import { resolveStructuredWorkerForDispatch, structuredWorkerOwned } from '../../orchestration-structured-worker-lifecycle' +import { structuredWorkerResourceReleased } from '../../../../structured-worker-authority' import type { DispatchContextRow, FederatedDispatchRow, @@ -53,7 +54,12 @@ export async function inspectWorkerTerminal( processIncarnation: structured.processIncarnation }) const observation = observeStructuredWorker(structured) - const addressable = structuredWorkerOwned(structured.sessionId) + const owned = structuredWorkerOwned(structured.sessionId) + const addressable = + owned === null + ? null + : owned && + !structuredWorkerResourceReleased(db.getWorkerTerminalResourceByHandle(structured.handle)) return { terminal: null, exact, diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index e7c78efcd79f..f8c96c364eb5 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -155,6 +155,44 @@ describe('an open dispatch keeps its worker running (P2-19 i)', () => { expect(resolveStructuredWorkerAuthority(handle, db)).toBeNull() }) + it('stops routing to a worker its coordinator abandoned and then released, and keeps its tab', () => { + const dispatch = dispatchWorker() + db.markWorkerDispatchReady(dispatch.id) + structuredWorkerIdentities.clear() + const tabs = [SESSION] + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped by the sweep', observedAt: 1 } + }), + tabs + ) + const resource = db.getWorkerTerminalResourceByOwner(dispatch.id)! + const handle = resource.terminal_handle + const recipients = () => listAddressableStructuredWorkers(db) + // At rest, its dispatch abandoned: still a recipient, as a terminal worker left running is. + db.abandonWorkerDispatch(dispatch.id) + const worktreeGroup = () => + resolveGroupAddress('@worktree:wt_1', 'term_sender', recipients(), () => 'idle') + expect(worktreeGroup()).toEqual([handle]) + expect(resolveStructuredWorkerAuthority(handle, db)).not.toBeNull() + + // The release finds the agent at rest, so it settles as released. + expect(db.requestWorkerTerminalRelease(dispatch.id)).toMatchObject({ disposition: 'retained' }) + expect( + db.settleDeadWorkerTerminalRelease({ + requestingDispatchId: dispatch.id, + resourceId: resource.id, + processIncarnation: resource.process_incarnation! + }) + ).toMatchObject({ disposition: 'released' }) + + expect(tabs).toEqual([SESSION]) + expect(worktreeGroup()).toEqual([]) + // Direct mail routes through the same answer. + expect(resolveStructuredWorkerAuthority(handle, db)).toBeNull() + }) + it('reads only this host scope, and no database answers no', () => { dispatchWorker(JSON.stringify({ kind: 'ssh', targetId: 'elsewhere' })) expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(false) diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index eec3cbb798fd..21edba11d69a 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -12,6 +12,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' +import type { WorkerTerminalResourceRow } from './orchestration/worker-terminal-ownership' import { isStructuredWorkerHandle, structuredWorkerIdentities, @@ -77,7 +78,8 @@ function structuredWorkerTabListed( } } -/** Identity plus a record that still proves this runtime owns the session. */ +/** Identity plus a record that still proves this runtime owns the session, for a worker its + * orchestration has not released. */ export function resolveStructuredWorkerAuthority( handle: string, db: OrchestrationDb | null | undefined @@ -87,7 +89,19 @@ export function resolveStructuredWorkerAuthority( return null } const record = readStructuredAgentSessionRecord(identity.sessionId) - return record && structuredWorkerOwned(identity.sessionId) ? { identity, record } : null + return record && + structuredWorkerOwned(identity.sessionId) && + !structuredWorkerResourceReleased(db?.getWorkerTerminalResourceByHandle?.(identity.handle)) + ? { identity, record } + : null +} + +/** The orchestration released this worker: its coordinator is done with it, as with a terminal + * worker whose terminal closed, so nothing routes to it. Its chat stays the user's. */ +export function structuredWorkerResourceReleased( + row: Pick | undefined +): boolean { + return row?.release_state === 'released' || row?.ownership_state === 'released' } /** From 75f5607201bb77fcd9416da751b422525301c60e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:30:44 -0700 Subject: [PATCH 072/101] fix(native-chat): a failed retry names the user's prompt, not Orca's continuation A resume's continuation is written to the chat before its start, so after a failed attempt the chat's newest user message is that rejected continuation. A second failure then showed Orca's own restart text as the chat's prompt. A retry now keeps the prompt its first failure named. --- ...tured-agent-session-restart-failure-ledger.ts | 11 ++++++++--- ...gent-session-restart-offer-withdrawal.test.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) 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 ed7e508307a5..9b5a89c313ae 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 @@ -138,9 +138,14 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { } const completed: string[] = [] const failures: AgentSessionResumeFailureInput[] = [] - const promptBySession = new Map( - action.candidates.map((candidate) => [candidate.sessionId, candidate.latestPrompt]) - ) + // A retry keeps the prompt its first failure named: the chat's newest user message since then + // is the rejected continuation, and a message of the user's own would have ended the offer. + const promptBySession = new Map([ + ...action.candidates.map( + (candidate) => [candidate.sessionId, candidate.latestPrompt] as const + ), + ...(await read()).map((filed) => [filed.marker.sessionId, filed.latestPrompt] as const) + ]) for (const outcome of outcomes) { const resumed = outcome.outcome === 'resumed' // Ineligible means the offer no longer applies (record gone, conversation forked), and 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 8c601155bbf1..3d78c9f640c6 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 @@ -259,6 +259,22 @@ it('keeps a failed resume retryable more than a day later, after the ledger prun expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) }) +// The rejected continuation is the chat's newest user message; the row still names the user's. +it("names the user's prompt on a failed retry, not the rejected continuation", async () => { + const state = await offered('submission') + const { host } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ latestPrompt: 'Perform the original task' }]) + + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + + expect(retried.failed).toMatchObject([ + { latestPrompt: 'Perform the original task', retryable: true } + ]) +}) + // Resume that runs by itself at launch goes through the same call a click does, and the same rule // decides: whichever of the two was accepted first since the restart wins. it("refuses an automatic continuation when the user's message was accepted first, and writes nothing", async () => { From 7a5e2024796295dc0b271482042a145b829cfcc6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:41:11 -0700 Subject: [PATCH 073/101] fix(orchestration): read the released row optionally, as the authority does worker-show's observation called the row lookup directly, which a runtime double without it threw on and failed the structured tab-retirement release. --- .../rpc/methods/orchestration/worker/worker-observation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index 3448bd2c96d8..e57c5882f785 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -59,7 +59,9 @@ export async function inspectWorkerTerminal( owned === null ? null : owned && - !structuredWorkerResourceReleased(db.getWorkerTerminalResourceByHandle(structured.handle)) + !structuredWorkerResourceReleased( + db.getWorkerTerminalResourceByHandle?.(structured.handle) + ) return { terminal: null, exact, From c0ea360e7fc03cc5a7d0ed481c6ca07a993fa495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:47:14 -0700 Subject: [PATCH 074/101] fix(native-chat): the status bar drops a restart offer the chat moved on from The renderer re-read the host's restart offer only when a failed chat showed activity, so after a message withdrew a pending offer the host answered no chats while the status bar kept counting one, and clicking it opened nothing. The same watch now covers pending offers: a status change in an offered chat asks the host again, once. --- ...tive-chat-resume-failed-chat-watch.test.ts | 25 +++++- .../native-chat-resume-on-restart-store.ts | 83 ++++++++++--------- 2 files changed, 66 insertions(+), 42 deletions(-) diff --git a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts index 308e7b878e89..7dfc8938373e 100644 --- a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts +++ b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts @@ -25,8 +25,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) vi.mock('sonner', () => ({ toast: vi.fn() })) -// After a restart action leaves a chat failed, the user's reply in that chat must retire the status -// bar entry without the user reopening anything, and nothing may run while nothing failed. +// When the user sends in an offered or failed chat, its status bar entry must retire without the +// user reopening anything, and nothing may run while nothing is offered or failed. const failure = { sessionId: 'a', @@ -91,7 +91,26 @@ afterEach(() => { vi.useRealTimers() }) -it('opens no status stream while nothing failed', async () => { +// The host withdraws a pending offer once the chat moves on; the status bar must not keep counting it. +it('re-reads once when the user sends in an offered chat, and the count drops', async () => { + const { failedAt: _failedAt, outcome: _outcome, reason: _reason, ...candidate } = failure + mocks.rpc + .mockResolvedValueOnce({ sessions: [candidate], failed: [] }) + .mockResolvedValue({ sessions: [], failed: [] }) + await refreshNativeChatRestartOffer() + await vi.advanceTimersByTimeAsync(0) + expect(getNativeChatRestartOffer().candidates).toHaveLength(1) + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + + hostEmit()({ type: 'status', session: summary('working', 'Something else', Date.now() + 10_000) }) + await vi.advanceTimersByTimeAsync(500) + + expect(offerReads()).toBe(2) + expect(getNativeChatRestartOffer().candidates).toEqual([]) + expect(mocks.unsubscribe).toHaveBeenCalledOnce() +}) + +it('opens no status stream while nothing is offered or failed', async () => { mocks.rpc.mockResolvedValue({ sessions: [], failed: [] }) await refreshNativeChatRestartOffer() await vi.advanceTimersByTimeAsync(0) diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index a3012495a3c4..78c5fa207b67 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -58,42 +58,47 @@ const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const * actually moves the offer. */ function publish(next: NativeChatRestartOffer): void { offer = next - syncFailedChatWatch() + syncOfferedChatWatch() for (const listener of listeners) { listener() } } /** - * Re-reads the host once a failed chat shows new activity, so a reply the user sent there retires - * its entry here too. The host stays the judge; this only asks again. + * Re-reads the host once an offered or failed chat shows new activity, so a message the user sent + * there, or its agent starting, retires its entry here too. The host stays the judge; this only + * asks again. * - * Held only while something failed. Keyed on status and prompt rather than every summary, so an - * agent streaming in a failed chat costs one re-read, not one per tool call. + * Held only while something is offered or failed. Keyed on status and prompt rather than every + * summary, so an agent streaming in such a chat costs one re-read, not one per tool call. */ -const FAILED_CHAT_REFRESH_DELAY_MS = 500 -let failedChatWatch: { +const OFFERED_CHAT_REFRESH_DELAY_MS = 500 +let offeredChatWatch: { feed: StructuredAgentSessionStatusFeedOwner seen: Map release: () => void } | null = null -let failedChatRefresh: ReturnType | null = null +let offeredChatRefresh: ReturnType | null = null -function failedChatActivityKey(summary: AgentSessionStatusSummary): string { +function offeredChatIds(): Set { + return new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)) +} + +function offeredChatActivityKey(summary: AgentSessionStatusSummary): string { return `${summary.status ?? ''}\u0000${summary.latestPrompt}` } -function syncFailedChatWatch(): void { - const failedIds = new Set(offer.failed.map((failure) => failure.sessionId)) - if (failedIds.size === 0) { - releaseFailedChatWatch() +function syncOfferedChatWatch(): void { + const offeredIds = offeredChatIds() + if (offeredIds.size === 0) { + releaseOfferedChatWatch() return } - if (!failedChatWatch) { + if (!offeredChatWatch) { const feed = getStructuredAgentSessionStatusFeed(LOCAL) - const unsubscribe = feed.subscribe(noticeFailedChatActivity) + const unsubscribe = feed.subscribe(noticeOfferedChatActivity) const deactivate = feed.activate() - failedChatWatch = { + offeredChatWatch = { feed, seen: new Map(), release: () => { @@ -102,60 +107,60 @@ function syncFailedChatWatch(): void { } } } - const { feed, seen } = failedChatWatch + const { feed, seen } = offeredChatWatch for (const sessionId of seen.keys()) { - if (!failedIds.has(sessionId)) { + if (!offeredIds.has(sessionId)) { seen.delete(sessionId) } } // What the feed already holds is what this listing answered. - for (const sessionId of failedIds) { + for (const sessionId of offeredIds) { const summary = feed.getSnapshot().get(sessionId) if (summary && !seen.has(sessionId)) { - seen.set(sessionId, failedChatActivityKey(summary)) + seen.set(sessionId, offeredChatActivityKey(summary)) } } } -function noticeFailedChatActivity(): void { - if (!failedChatWatch) { +function noticeOfferedChatActivity(): void { + if (!offeredChatWatch) { return } - const { feed, seen } = failedChatWatch + const { feed, seen } = offeredChatWatch const snapshot = feed.getSnapshot() let changed = false - for (const failure of offer.failed) { - const summary = snapshot.get(failure.sessionId) + for (const sessionId of offeredChatIds()) { + const summary = snapshot.get(sessionId) if (!summary) { continue } - const key = failedChatActivityKey(summary) - const previous = seen.get(failure.sessionId) + const key = offeredChatActivityKey(summary) + const previous = seen.get(sessionId) if (previous !== key) { - seen.set(failure.sessionId, key) + seen.set(sessionId, key) // A first sighting is news only if newer than the list; a change to a known chat always is, // since the host may have answered the list just before the change was delivered here. changed ||= previous !== undefined || summary.updatedAt > offer.listedAt } } - if (changed && failedChatRefresh === null) { - failedChatRefresh = setTimeout(() => { - failedChatRefresh = null + if (changed && offeredChatRefresh === null) { + offeredChatRefresh = setTimeout(() => { + offeredChatRefresh = null if (actionsBegun === actionsSettled) { const issued = actionsBegun void readNativeChatRestartOffer(() => actionsBegun === issued) } - }, FAILED_CHAT_REFRESH_DELAY_MS) + }, OFFERED_CHAT_REFRESH_DELAY_MS) } } -function releaseFailedChatWatch(): void { - if (failedChatRefresh !== null) { - clearTimeout(failedChatRefresh) - failedChatRefresh = null +function releaseOfferedChatWatch(): void { + if (offeredChatRefresh !== null) { + clearTimeout(offeredChatRefresh) + offeredChatRefresh = null } - failedChatWatch?.release() - failedChatWatch = null + offeredChatWatch?.release() + offeredChatWatch = null } export function getNativeChatRestartOffer(): NativeChatRestartOffer { @@ -358,7 +363,7 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { - releaseFailedChatWatch() + releaseOfferedChatWatch() offer = EMPTY actionsBegun = 0 actionsSettled = 0 From 6e319c4edd2d2994164bb150e9930d625238e36a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:09:01 -0700 Subject: [PATCH 075/101] feat(native-chat): list restored chats from durable state and their saved status A restarted host used to open every listed chat's journal before it answered the tab inventory: the sweep replayed each journal, and the tab list came from whatever it managed to open, so one unreadable journal lost its tab and a large profile held every chat tab behind every journal. The tab restore now waits only for the host install and lists every chat the index holds, from the records, in one store write per workspace. Reconcile, recovery and journal opens move into one startup pass kicked from prepare: - phase 1 publishes each listed chat's saved settled status when its journal still stands at the position that status was computed at (a read-only probe of the journal's epoch and newest row), and remembers the rest; - phase 2, after reconcile, stops surviving owners of every resolvable record, settles every open conversation, then opens (unless open), settles and closes again each miss and each listed record still owing settlement, active workspace first, one chat at a time behind any reader. The saved status is a host-local SQLite cache, written coalesced off the hot path and checked against the journal on every boot, so a stale entry is never shown. A later clean reconcile settles open conversations again, and a user close of a restored chat before its tab is listed now retires it. --- docs/reference/agent-status-store.md | 6 +- .../journal-cursor-probe.test.ts | 109 ++++++ .../journal-cursor-probe.ts | 56 +++ ...tructured-agent-session-client-delivery.ts | 5 + ...red-agent-session-conversation-lifetime.ts | 29 ++ .../structured-agent-session-host-tabs.ts | 32 +- .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-host.test.ts | 10 +- .../structured-agent-session-host.ts | 40 ++- ...gent-session-legacy-handoff-record.test.ts | 10 +- ...red-agent-session-provider-restore.test.ts | 4 +- ...uctured-agent-session-read-restore.test.ts | 115 ------ .../structured-agent-session-read-restore.ts | 32 -- ...ed-agent-session-readable-restorer.test.ts | 46 --- ...uctured-agent-session-readable-restorer.ts | 42 --- ...tured-agent-session-recovery-exits.test.ts | 6 +- ...t-session-restart-offer-withdrawal.test.ts | 2 +- ...uctured-agent-session-restart-reconcile.ts | 4 +- ...agent-session-restart-restore-gate.test.ts | 27 -- ...ured-agent-session-restart-restore-gate.ts | 17 - ...ured-agent-session-restart-restore.test.ts | 150 -------- ...tructured-agent-session-restart-restore.ts | 333 ++++++++++++++---- ...session-restart-status-publication.test.ts | 6 +- .../structured-agent-session-reveal.ts | 35 -- ...agent-session-settled-attach-retry.test.ts | 2 +- ...ructured-agent-session-settlement-retry.ts | 15 +- ...red-agent-session-startup-pass-test-rig.ts | 261 ++++++++++++++ ...uctured-agent-session-startup-pass.test.ts | 309 ++++++++++++++++ ...d-agent-session-startup-settlement.test.ts | 247 +++++++++++++ ...tructured-agent-session-status-equality.ts | 52 +++ .../structured-agent-session-status-feed.ts | 126 ++++--- ...red-agent-session-surface-lifetime.test.ts | 10 +- ...t-session-wedged-profile-migration.test.ts | 31 +- .../agent-session-saved-status-store.test.ts | 155 ++++++++ .../agent-session-saved-status-store.ts | 272 ++++++++++++++ .../orca-runtime-close-mobile-session-tab.ts | 4 +- ...time-close-structured-agent-session-tab.ts | 52 ++- ...structured-agent-session-create-support.ts | 12 +- ...tore-structured-agent-session-tabs-once.ts | 143 ++++---- ...runtime-structured-session-restore.test.ts | 271 +++++++------- ...ed-structured-agent-session-restoration.ts | 26 ++ ...uctured-agent-session-runtime-exit.test.ts | 2 +- ...ructured-agent-session-runtime-teardown.ts | 3 + .../structured-agent-session-runtime.ts | 4 + .../structured-agent-session-tab-append.ts | 84 +++++ ...gent-session-unpublished-tab-close.test.ts | 73 ++++ ...uctured-agent-session-saved-status.test.ts | 124 +++++++ .../structured-agent-session-saved-status.ts | 71 ++++ ...ss-version-agent-session-wire.unit.test.ts | 6 +- 49 files changed, 2615 insertions(+), 859 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-cursor-probe.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 create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass-test-rig.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-equality.ts create mode 100644 src/main/runtime/agent-session-saved-status-store.test.ts create mode 100644 src/main/runtime/agent-session-saved-status-store.ts create mode 100644 src/main/runtime/structured-agent-session-tab-append.ts create mode 100644 src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts create mode 100644 src/shared/structured-agent-session-saved-status.test.ts create mode 100644 src/shared/structured-agent-session-saved-status.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 4dff79d6666a..8d91532da288 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -119,7 +119,11 @@ forgot the row would strand a permanently working-looking agent. Two rules the ingest must keep: - **Never persist a structured row.** The journal is the durable truth for a - structured session and the host republishes on restore. A structured row in + structured session and the host republishes on restore: for each listed chat, + from its saved settled status when its journal still stands at the position + that status was computed at, and otherwise from the journal. That saved copy + is the host's own cache (`agent-session-saved-status.db`), never this store's + file. A structured row in `last-status.json` would hydrate as `restoredUnconfirmed` and then fight the live republish. The serializer skips rows carrying `structuredHost`, and hydrate drops any such row found on disk. Applying one therefore also skips diff --git a/src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts b/src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts new file mode 100644 index 000000000000..ca1f533465b4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts @@ -0,0 +1,109 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { probeJournalCursor } from './journal-cursor-probe' +import { journalDatabaseFile } from './journal-paths' +import { openAgentSessionJournal } from './journal-store-factory' + +const SESSION = 'session-alpha' +let dirs: string[] = [] + +afterEach(() => { + dirs.forEach((dir) => rmSync(dir, { recursive: true, force: true })) + dirs = [] +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-journal-probe-')) + dirs.push(dir) + return dir +} + +/** A closed, checkpointed journal holding one message, and the position its open computes. */ +async function settledJournal(journalDir: string) { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + const cursor = journal.cursor() + await journal.close() + return cursor +} + +describe('journal cursor probe', () => { + it('reads the position the open computes and leaves the database file byte-identical (C13)', async () => { + const journalDir = tempDir() + const cursor = await settledJournal(journalDir) + const dbPath = journalDatabaseFile(journalDir) + const before = readFileSync(dbPath) + + expect(probeJournalCursor(journalDir, SESSION)).toEqual(cursor) + + expect(readFileSync(dbPath).equals(before)).toBe(true) + if (process.platform !== 'win32') { + // A read-only open of a WAL file may leave sidecars behind; they stay owner-only. + for (const sidecar of [`${dbPath}-wal`, `${dbPath}-shm`].filter((path) => existsSync(path))) { + expect(statSync(sidecar).mode & 0o777).toBe(0o600) + } + } + }) + + it('reads a turn a crash left only in the write-ahead log (C15)', async () => { + const journalDir = tempDir() + const settled = await settledJournal(journalDir) + const dbPath = journalDatabaseFile(journalDir) + // A later turn commits into the WAL, and the process dies before any checkpoint. + const writer = new Database(dbPath) + writer.pragma('wal_autocheckpoint = 0') + writer.exec('BEGIN IMMEDIATE') + const insert = writer.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + for (let offset = 1; offset <= 5; offset += 1) { + insert.run(SESSION, settled.epoch, settled.sequence + offset, 1, '{}') + } + writer.exec('COMMIT') + const crashed = join(tempDir(), 'journal') + mkdirSync(crashed) + copyFileSync(dbPath, journalDatabaseFile(crashed)) + copyFileSync(`${dbPath}-wal`, `${journalDatabaseFile(crashed)}-wal`) + writer.close() + + expect(probeJournalCursor(crashed, SESSION)).toEqual({ + epoch: settled.epoch, + sequence: settled.sequence + 5 + }) + }) + + it('misses, rather than throws, on a file that is not a database', () => { + const journalDir = tempDir() + mkdirSync(journalDir, { recursive: true }) + const dbPath = journalDatabaseFile(journalDir) + rmSync(dbPath, { force: true }) + expect(probeJournalCursor(journalDir, SESSION)).toBeNull() + writeFileSync(dbPath, Buffer.alloc(8192, 7)) + expect(probeJournalCursor(journalDir, SESSION)).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor-probe.ts b/src/main/native-chat/agent-session-journal/journal-cursor-probe.ts new file mode 100644 index 000000000000..1260113768f8 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-cursor-probe.ts @@ -0,0 +1,56 @@ +// Where a journal stands, read without opening it for use. +// +// A restarted host lists a chat from its saved status only while the journal is still exactly where +// that status was computed. This reads the position the open would compute — the live epoch and its +// newest row — on a read-only connection that sets no pragma and runs no DDL, so the file is left +// byte-identical. Any doubt is a miss (null): the caller then opens the journal properly. +// +// Never `immutable` or `nolock`: those skip the `-wal`, and a turn a crash left only in the WAL +// would read as the old position and bring back a stale "done". + +import { existsSync } from 'node:fs' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import Database from '../../sqlite/sync-database' +import { journalPragmaNumber } from './journal-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { journalDatabaseFile } from './journal-paths' +import { pendingJournalRepairSequence } from './journal-repair-marker' +import { readJournalSessionEpoch } from './journal-row-table' + +const SELECT_NEWEST_SEQUENCE = + 'SELECT seq FROM journal_rows WHERE session_id = ? AND epoch = ? ORDER BY seq DESC LIMIT 1' + +export function probeJournalCursor( + journalDir: string, + sessionId: string +): AgentJournalCursor | null { + const dbPath = journalDatabaseFile(journalDir) + if (!existsSync(dbPath)) { + return null + } + let db: Database.Database | null = null + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }) + if (journalPragmaNumber(db, 'user_version') !== JOURNAL_DB_SCHEMA_VERSION) { + return null + } + const epoch = readJournalSessionEpoch(db, sessionId) + if (!epoch || pendingJournalRepairSequence(db, sessionId, epoch) !== null) { + return null + } + const newest = db.prepare(SELECT_NEWEST_SEQUENCE).get(sessionId, epoch) + const sequence: unknown = newest?.seq + return typeof sequence === 'number' ? { epoch, sequence } : null + } catch { + return null + } finally { + try { + db?.close() + } catch { + // A close that fails leaves nothing this probe can use; the answer stands. + } + // A read-only open of a WAL file leaves `-wal`/`-shm` behind at umask permissions. + hardenSqliteDatabaseFiles(dbPath) + } +} 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 469769cb57ec..dfbd3341d682 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 type { StructuredAgentSessionSavedStatus } from '../../../shared/structured-agent-session-saved-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import type { @@ -60,6 +61,10 @@ export class StructuredAgentSessionClientDelivery { publishRestored = (sessionId: string): void => this.statusFeed.publish(sessionId, undefined, { replay: true }) + seedStatus = (sessionId: string, saved: StructuredAgentSessionSavedStatus): void => { + this.statusFeed.seedRestored(sessionId, saved) + } + subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) => this.statusFeed.subscribe(subscriber) forgetStatus = (sessionId: string): void => this.statusFeed.forget(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 6d16e3db5ca6..bd9f95de0bb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -18,6 +18,7 @@ import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { isUnusableSqliteDatabaseError } from '../../sqlite/sqlite-read-failure' +import type { StructuredAgentSessionStartupStep } from './structured-agent-session-restart-restore' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -35,6 +36,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { closeStatus: (sessionId: string, options: { listed: boolean }) => void }) { let disposed = false + // Sessions inside a startup step, and whether any `conversation()` caller reached one meanwhile. + const reachedDuringStartupStep = new Map() const { sessions, serialize } = host const deps = () => host.context().deps // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. @@ -93,6 +96,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { * drop it after. */ conversation: async (sessionId: string): Promise => { + if (reachedDuringStartupStep.has(sessionId)) { + reachedDuringStartupStep.set(sessionId, true) + } const open = sessions.get(sessionId) if (open) { return open @@ -121,6 +127,29 @@ export function createStructuredAgentSessionConversationLifetime(host: { return session }) }, + /** The startup pass's per-chat step, under the session's lock and never after quit began. A + * conversation the step opens only to derive status is closed again unless a reader reached + * it meanwhile or it has a child. */ + startupStep: ((sessionId, step) => + serialize(sessionId, async () => { + if (disposed) { + return undefined + } + reachedDuringStartupStep.set(sessionId, false) + try { + return await step({ + session: sessions.get(sessionId), + closeIfUnreached: async () => { + const session = sessions.get(sessionId) + if (session && !session.child && !reachedDuringStartupStep.get(sessionId)) { + await closeConversation(sessionId) + } + } + }) + } finally { + reachedDuringStartupStep.delete(sessionId) + } + })) satisfies StructuredAgentSessionStartupStep, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent. */ close: (sessionId: string): Promise => 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 3bd6e46b98eb..9fd25d304f4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' /** * Chat-tab visibility is the deletion funnel: every path that removes a chat as a user-facing @@ -30,15 +31,26 @@ export type StructuredAgentSessionTab = { agent: AgentSessionRecord['provider'] } -export function listStructuredAgentSessionTabs( - sessions: ReadonlyMap< - string, - { params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } } - > +/** Tab existence from durable state: every requested id with a record this host serves, whether + * or not its journal opens. A chat that cannot be read keeps its tab and explains itself there. */ +export function listPersistedStructuredAgentSessionTabs( + deps: { + store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + adapter: Parameters[0] + }, + sessionIds: readonly string[] ): StructuredAgentSessionTab[] { - return [...sessions.entries()].map(([sessionId, session]) => ({ - sessionId, - workspaceId: session.params.location.workspaceId, - agent: session.params.provider - })) + const tabs = new Map() + for (const sessionId of sessionIds) { + const record = deps.store.getRecord(sessionId) + if (!record || tabs.has(sessionId) || !adapterSupportsRecord(deps.adapter, record)) { + continue + } + tabs.set(sessionId, { + sessionId, + workspaceId: record.location.workspaceId, + agent: record.provider + }) + } + return [...tabs.values()] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 461442717980..23a04ffcb6f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -4,6 +4,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import type { AgentSessionSavedStatusStore } from '../../runtime/agent-session-saved-status-store' import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { @@ -121,6 +122,8 @@ export type StructuredAgentSessionHostDeps = { * removed from. Both production hosts pass one — the desktop and headless `orcad`; absent, * every reader of that store simply lists no structured session. */ statusSink?: StructuredAgentSessionStatusSink + /** Each chat's last settled status, for the startup pass; absent, every listed chat is opened. */ + savedStatus?: Pick /** Host model catalog surface; absent means every catalog read answers `unknown`. */ modelCatalog?: AgentModelCatalogService } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index e65e1bd33fc7..37a57b8274a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -594,11 +594,11 @@ describe('restart', () => { acquire.mockClear() const listRecords = vi.spyOn(store, 'listRecords') - await host.restoreReadableSessions() + await host.restoreStartupSessions() const restoreReads = listRecords.mock.calls.length - await host.restoreReadableSessions() + await host.restoreStartupSessions() - expect(host.listSessionTabs()).toEqual([ + expect(host.listPersistedSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) const history = await host.history({ sessionId: SESSION, direction: 'tail' }) @@ -621,7 +621,7 @@ describe('restart', () => { await reboot(async () => ({ outcome: 'pid-absent' })) acquire.mockClear() - await host.restoreReadableSessions() + await host.restoreStartupSessions() // The recovery stage clears on evidence at startup; the child comes back only once work // starts it — here the explicit attach a send's delivery would make. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -644,7 +644,7 @@ describe('restart', () => { it('answers the owner status of a starting chat once its start settles', async () => { await attach() await reboot(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() + await host.restoreStartupSessions() const started = Promise.withResolvers() const release = Promise.withResolvers() const settled = acquire.getMockImplementation() 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 850c5298bec7..427683d26fb3 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 @@ -14,10 +14,12 @@ import { createRestartReconciler } from './structured-agent-session-restart-reco import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' +import { revealStructuredAgentSession } from './structured-agent-session-reveal' import { - createStructuredAgentSessionHostRestore, - revealStructuredAgentSession -} from './structured-agent-session-reveal' + createStructuredAgentSessionHostStartupPass, + type StructuredAgentSessionStartupPass, + type StructuredAgentSessionStartupPriority +} from './structured-agent-session-restart-restore' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' @@ -83,7 +85,7 @@ export class StructuredAgentSessionHost { private readonly reconcileLeases: ( sessionId: string ) => Promise - private readonly restore: ReturnType + private readonly startupPass: StructuredAgentSessionStartupPass private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: ReturnType< typeof createStructuredAgentSessionConversationDelivery @@ -108,7 +110,8 @@ export class StructuredAgentSessionHost { store: deps.store, probe: (record) => this.runtimeState.probeRecord(record), ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), - now: () => this.now() + now: () => this.now(), + onReconciled: () => this.startupPass.onReconciled() }) this.conversationDelivery = createStructuredAgentSessionConversationDelivery({ deps, @@ -127,15 +130,6 @@ export class StructuredAgentSessionHost { ), publishRestored: this.clientDelivery.publishRestored }) - this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { - reconcile: this.reconcileLeases, - resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), - serialize: (sessionId, task) => this.serialize(sessionId, task), - hasSession: this.hasSession, - // Site 10: cannot overwrite a live entry — the restorer returns early on - // `hasSession` inside the same serialized step as this `set`. - onReadable: this.conversationDelivery.adoptOpened - }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, store: deps.store, @@ -165,6 +159,15 @@ export class StructuredAgentSessionHost { deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options) }) + this.startupPass = createStructuredAgentSessionHostStartupPass(deps, { + sessions: this.sessions, + step: this.lifetime.startupStep, + seed: (sessionId, saved) => this.clientDelivery.seedStatus(sessionId, saved), + reconcile: () => this.reconcileLeases('startup'), + resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), + open: (sessionId) => this.conversationDelivery.open(sessionId), + now: () => this.now() + }) this.runtimeState.startLeaseRenewal() this.lifetime.idleSweep.start() } @@ -204,7 +207,9 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) - listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) + /** The tabs the durable index lists, from records alone: no journal is read. */ + listPersistedSessionTabs = (sessionIds: readonly string[]) => + sessionTabs.listPersistedStructuredAgentSessionTabs(this.deps, sessionIds) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() setSessionTabVisibility = async (sessionId: string, visible: boolean): Promise => { @@ -222,8 +227,9 @@ export class StructuredAgentSessionHost { } } - restoreReadableSessions = (sessionIds?: readonly string[]): Promise => - this.restore.restoreReadableSessions(sessionIds) + /** The one startup pass: list status from saved copies, then settle and open what that left. */ + restoreStartupSessions = (priority?: StructuredAgentSessionStartupPriority): Promise => + this.startupPass.run(priority) /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => 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 7c34b4f6b82d..85ebc344610c 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 @@ -62,6 +62,8 @@ function openHost(): void { /** Writes the lease an older build left behind, then starts a fresh app generation over it. */ async function persistFromOlderBuild(lease: Partial): Promise { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + // Listed, as a chat with a tab is: the startup pass settles what a listed chat owes. + await store.setSessionTabVisibility(SESSION, true) const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() // Over the attached owner: the older build's stage or terminal owner kept it from releasing. @@ -154,7 +156,7 @@ describe('a record an older build left mid terminal handoff', () => { claimStatus: 'released' }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -188,7 +190,7 @@ describe('a record an older build left mid terminal handoff', () => { }) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - await host.restoreReadableSessions() + await host.restoreStartupSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -208,7 +210,7 @@ describe('a record an older build left mid terminal handoff', () => { claimStatus: 'live' }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -231,7 +233,7 @@ describe('a record an older build left mid terminal handoff', () => { }) probe.mockResolvedValue({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'conflicted', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index ced2d2359216..04106c124fde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -98,9 +98,9 @@ describe('structured session provider restore', () => { reason: 'read does not need ownership' })) - await restarted.restoreReadableSessions() + await restarted.restoreStartupSessions() - expect(restarted.listSessionTabs()).toEqual([ + expect(restarted.listPersistedSessionTabs([HOST_TEST_SESSION])).toEqual([ { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts deleted file mode 100644 index 514008e49f9b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Read restore decides whether a session comes back at all. -// -// A chat still in the pre-SQLite format has no `journal.db`, so the probe that -// loads one reports nothing. Reading that as "no session" is what removed these -// chats: an unpublished session is also what prunes its tab out of the saved -// workspace, so the tab is gone before anything can explain itself. - -import { mkdir, mkdtemp, rm, writeFile } 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 { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' - -const SESSION_ID = 'codex_read_restore_fixture' -const WORKSPACE_ID = 'repo-1::/tmp/workspace' - -const RECORD = { - schemaVersion: 2, - sessionId: SESSION_ID, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE_ID, - workspaceKind: 'git-worktree' - }, - provider: 'codex', - providerHandleChain: [ - { - linkId: 'codex-1-thread-1', - handle: { provider: 'codex', threadId: 'thread-1' }, - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ], - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - createdAt: 1, - updatedAt: 2, - lease: { sessionId: SESSION_ID, runtimeKind: 'native', runtimeFence: 1 } -} as unknown as AgentSessionRecord - -const store = { - getRecord: (sessionId: string) => (sessionId === SESSION_ID ? RECORD : null) -} as unknown as AgentSessionRecordStore - -let journalRoot: string -const openDeps = () => ({ - store, - journalRoot, - adapter: {} -}) -const opened: AgentSessionJournal[] = [] - -async function writeRemnant(name: string): Promise { - const dir = journalDirectoryFor(journalRoot, { - workspaceId: WORKSPACE_ID, - sessionId: SESSION_ID - }) - await mkdir(dir, { recursive: true }) - await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') - return join(dir, name) -} - -beforeEach(async () => { - journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-')) -}) - -afterEach(async () => { - await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) - await rm(journalRoot, { recursive: true, force: true }) -}) - -describe('a session whose journal is still the pre-SQLite format', () => { - it('is published, carrying the message that explains it', async () => { - const transcript = await writeRemnant('log.jsonl') - - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) - const disclosed = restored!.session.journal - .snapshot() - .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) - expect(disclosed.join('')).toContain(transcript) - // Publishing it costs no agent process; acquisition still waits for the user. - expect(restored!.session.child).toBeNull() - }) - - it('is published for a remnant whose log is gone', async () => { - await writeRemnant('snapshot.json') - - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) - }) - - it('still drops a session with neither a journal nor a remnant', async () => { - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).toBeNull() - }) - - it('still drops a session with no record', async () => { - await writeRemnant('log.jsonl') - - 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 0e1441b729c7..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' -import { existsSync } from 'node:fs' -import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' -import { - openStructuredAgentSessionConversationJournal, - type OpenedStructuredAgentSessionConversation, - type StructuredAgentSessionConversationOpenDeps -} from './structured-agent-session-conversation-open' - -/** - * A reader's open: the conversation's own open, for a session that has a journal to read. One - * with none — never written, or gone — stays unpublished rather than founding an empty one. - * Opening can still write: the crash boundary, and the row explaining an old-format history. - */ -export async function restoreStructuredAgentSessionRead( - deps: StructuredAgentSessionConversationOpenDeps, - sessionId: string -): Promise { - const record = deps.store.getRecord(sessionId) - if (!record) { - return null - } - const journalDir = journalDirectoryFor(deps.journalRoot, { - workspaceId: record.location.workspaceId, - sessionId - }) - // A session still in the pre-SQLite format has no `journal.db`; the open imports it. - if (!existsSync(journalDatabaseFile(journalDir)) && !findJournalFileFormatRemnant(journalDir)) { - 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 6a105fa0c1d0..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' - -const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) - -vi.mock('./structured-agent-session-restart-restore', () => ({ - restoreStructuredAgentSessionsOnRestart: restoreOnRestart -})) - -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' - -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) => ({ sessionId }) as AgentSessionRecord - ) - const restorer = new StructuredAgentSessionReadableRestorer({ - openDeps: { - store: { getRecord: () => null, listRecords: () => records }, - journalRoot: '/tmp/journals', - adapter: {} - }, - supportsRecord: () => true, - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => undefined, - retrySettlement: async () => true - }) - - 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 06bd043aec0c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -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 & { - supportsRecord: (record: AgentSessionRecord) => boolean - } - ) {} - - 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 - .listRecords() - .filter( - (record) => - this.input.supportsRecord(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 1a5b53ac61fc..6e3dd56886ed 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 @@ -189,7 +189,7 @@ describe('recovery exits', () => { mintSpawnToken: () => 'spawn-c', probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() // Death proof releases the residue outright: no handoff continuation, no spawned child, // no stage a user would have to clear by hand. @@ -265,7 +265,7 @@ describe('recovery exits', () => { stopOwnerProcess }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but // nothing has asked this session for work, so no replacement child exists yet. @@ -326,7 +326,7 @@ describe('recovery exits', () => { } }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 2, claimStatus: 'released', 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 996862a7ea21..c148afb08843 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 @@ -122,7 +122,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 host.restoreStartupSessions() expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 3b6cc30271e5..b4a54ea0726e 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 @@ -20,6 +20,8 @@ export function createRestartReconciler(deps: { records: readonly AgentSessionRecord[] ) => Promise> now: () => number + /** A run that settled every lease; what it flagged is owed settlement now. */ + onReconciled?: () => void }): (sessionId: string) => Promise { let pending: Promise | null = null return async (sessionId) => { @@ -27,7 +29,7 @@ export function createRestartReconciler(deps: { return null } if (!pending) { - const run = reconcileCurrentLeases(deps) + const run = reconcileCurrentLeases(deps).then(() => deps.onReconciled?.()) pending = run.finally(() => { pending = null }) 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 4567d84d5c0d..000000000000 --- 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 dd4237dcb28b..000000000000 --- 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 2d83daa69762..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' - -const { restoreRead } = vi.hoisted(() => ({ - restoreRead: vi.fn() -})) - -vi.mock('./structured-agent-session-read-restore', () => ({ - restoreStructuredAgentSessionRead: restoreRead -})) - -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' - -const NO_OPEN_DEPS = { - store: { getRecord: () => null, listRecords: () => [] }, - journalRoot: '/tmp/journals', - adapter: {} -} - -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 - }, - reset: null - } - }) - const records = Array.from( - { length: 12 }, - (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord - ) - - const restoration = restoreStructuredAgentSessionsOnRestart({ - openDeps: NO_OPEN_DEPS, - records, - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => undefined, - retrySettlement: async () => true - }) - - 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('runs pending settlement retry after recovery resolution', 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' - } - restoreRead.mockResolvedValue({ - session: { - journal: {}, - params, - fence: 4, - child: null, - acquisitionGeneration: null - }, - reset: null - }) - - await restoreStructuredAgentSessionsOnRestart({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. - records: [{ sessionId: 'session-1' } as AgentSessionRecord], - openDeps: NO_OPEN_DEPS, - reconcile: async () => null, - resolveRecovery: async () => { - calls.push('resolveRecovery') - }, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => { - calls.push('onReadable') - }, - retrySettlement: async (_sessionId, restoredParams) => { - calls.push( - restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement' - ) - return true - } - }) - - expect(calls).toEqual(['resolveRecovery', 'onReadable', 'retrySettlement:restored-params']) - }) - - it('does not rerun settlement retry when a second restore finds the session already open', async () => { - const retrySettlement = vi.fn(async () => true) - restoreRead.mockResolvedValue({ - session: { - journal: {}, - params: {}, - fence: 4, - child: null, - acquisitionGeneration: null - }, - reset: null - }) - - await restoreStructuredAgentSessionsOnRestart({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. - records: [{ sessionId: 'session-1' } as AgentSessionRecord], - openDeps: NO_OPEN_DEPS, - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize: async (_sessionId, task) => task(), - hasSession: () => true, - onReadable: () => undefined, - retrySettlement - }) - - expect(retrySettlement).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 index e2532483b103..06266e1ab128 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -2,82 +2,297 @@ // // It owes reconciliation — every lease loaded from disk names an owner from a process generation // that no longer exists, and adjudicating that is startup's job. It owes an exit from any recovery -// stage the evidence now permits. And it owes a READABLE session: the journal open, history -// answerable, the tab restorable. +// stage the evidence now permits, a settlement for any turn a restart cut off, and a status row for +// every listed chat. It does not owe a provider child, and it does not owe an open journal: a chat +// whose saved status still matches its journal's position is listed from that copy, and its journal +// opens when something reads it. // -// It does not owe a provider child. This used to resume every record whose lease was `released`, -// which is the normal end state of a chat the user closed cleanly — so a -// healthy profile started an app-server per session it had ever used, in parallel, at every launch, -// with no client attached and nothing on screen. A child now exists because work asked for it — a -// send, through the delivery loop — not because a record survived on disk. +// One pass, kicked once after the host installs, in two phases: +// 1. before reconcile: each listed chat's saved status is checked against its journal's position; +// a match publishes the row, anything else is remembered as a miss; +// 2. after reconcile: recovery exits, then settlement of every open conversation, then each miss +// and each listed record still owing settlement is opened (unless already open), settled, and +// closed again if nothing but this pass wanted it open. +// Every step runs under its own session's lock with a yield between chats, so a read or a send +// jumps the queue and waits for one chat at most. +import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' +import { yieldToEventLoop } from '../../../shared/event-loop-yield' +import { + STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + type StructuredAgentSessionSavedStatus +} from '../../../shared/structured-agent-session-saved-status' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { sameJournalCursor } from '../agent-session-journal/journal-cursor' +import { probeJournalCursor } from '../agent-session-journal/journal-cursor-probe' +import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' +import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { - OpenedStructuredAgentSessionConversation, - StructuredAgentSessionConversationOpenDeps -} from './structured-agent-session-conversation-open' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { structuredSessionRecoveryIsResolvable } from './structured-agent-session-recovery-resolution' +import { + retryLoadedStructuredAgentSessionSettlement, + structuredAgentSessionOwesSettlement +} from './structured-agent-session-settlement-retry' -const JOURNAL_RESTORE_CONCURRENCY = 4 +/** Which chats a user is most likely to look at first; absent on hosts with no saved desktop + * session (`orca serve`, SSH), where the pass keeps the listing's order. */ +export type StructuredAgentSessionStartupPriority = { + activeWorkspaceId: string | null + visibleWorkspaceIds: ReadonlySet +} + +/** One chat's share of the pass, run under its lock. `session` is the conversation open when the + * step began; `closeIfUnreached` drops it again unless a reader reached it during the step. */ +export type StructuredAgentSessionStartupStep = ( + sessionId: string, + step: (context: { + session: StructuredAgentSessionHostSession | undefined + closeIfUnreached: () => Promise + }) => Promise +) => Promise -export type StructuredAgentSessionReadRestoreDeps = { - openDeps: StructuredAgentSessionConversationOpenDeps & { - store: Pick +export type StructuredAgentSessionStartupPassDeps = { + store: Pick + journalRoot: string + savedStatus?: { + read: (sessionId: string) => StructuredAgentSessionSavedStatus | null + prune: (keep: (sessionId: string) => boolean) => void } - reconcile: (sessionId: string) => Promise + supportsRecord: (record: AgentSessionRecord) => boolean + sessions: ReadonlyMap + step: StructuredAgentSessionStartupStep + seed: (sessionId: string, saved: StructuredAgentSessionSavedStatus) => void + reconcile: () => Promise resolveRecovery: (sessionId: string) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - hasSession: (sessionId: string) => boolean - onReadable: ( - sessionId: string, - opened: OpenedStructuredAgentSessionConversation - ) => Promise | void - retrySettlement: (sessionId: string, params: AgentSessionAttachParams) => Promise + /** The host's one open, for a caller inside the session's lock; it re-checks for an open entry. */ + open: (sessionId: string) => Promise + settle: (sessionId: string, journal: AgentSessionJournal) => Promise + onError: (sessionId: string, error: unknown) => void + now: () => number } -/** One session's share of the restart restore. Startup maps this over every supported record. */ -async function restoreOneStructuredAgentSessionRead( - input: StructuredAgentSessionReadRestoreDeps, - sessionId: string -): Promise { - const unreconciled = await input.reconcile(sessionId) - if (!unreconciled) { - // A session latched in recovery exits here at startup, without waiting for a client. - await input.resolveRecovery(sessionId) - } - await input.serialize(sessionId, () => - restoreOneStructuredAgentSessionReadUnderSerialize(input, sessionId) +export type StructuredAgentSessionStartupPass = { + /** Latched: every later call is the same pass, retried only after a failure. */ + run: (priority?: StructuredAgentSessionStartupPriority) => Promise + /** A later reconcile settled every lease: settle what it flagged on open conversations. */ + onReconciled: () => void +} + +/** A compaction or rewind the last run left prepared: the chat's open settles it and writes a row, + * so its saved status cannot equal what that open publishes. */ +function openOwesCommandSettlement(record: AgentSessionRecord): boolean { + const command = record.conversationCommand + return ( + (command?.command === 'compact' && command.phase === 'prepared') || + record.rewind?.phase === 'prepared' || + record.rewind?.phase === 'provider-succeeded' ) } -/** The serialized half of the restore. */ -async function restoreOneStructuredAgentSessionReadUnderSerialize( - input: Pick< - StructuredAgentSessionReadRestoreDeps, - 'openDeps' | 'hasSession' | 'onReadable' | 'retrySettlement' - >, - sessionId: string -): Promise { - if (input.hasSession(sessionId)) { - // A read or a send mid-restore already opened this one. - return +function journalDirFor(root: string, record: AgentSessionRecord): string { + return journalDirectoryFor(root, { + workspaceId: record.location.workspaceId, + sessionId: record.sessionId + }) +} + +/** Active workspace first, then the other visible ones, then the rest; listing order within each. */ +export function orderStartupSessionIds( + sessionIds: readonly string[], + workspaceOf: (sessionId: string) => string | undefined, + priority: StructuredAgentSessionStartupPriority | undefined +): string[] { + if (!priority) { + return [...sessionIds] } - const opened = await restoreStructuredAgentSessionRead(input.openDeps, sessionId) - if (!opened) { - return + const tier = (sessionId: string): number => { + const workspaceId = workspaceOf(sessionId) + if (workspaceId !== undefined && workspaceId === priority.activeWorkspaceId) { + return 0 + } + return workspaceId !== undefined && priority.visibleWorkspaceIds.has(workspaceId) ? 1 : 2 } - await input.onReadable(sessionId, opened) - await input.retrySettlement(sessionId, opened.session.params) + return sessionIds + .map((sessionId, index) => ({ sessionId, index, tier: tier(sessionId) })) + .sort((left, right) => left.tier - right.tier || left.index - right.index) + .map(({ sessionId }) => sessionId) } -export async function restoreStructuredAgentSessionsOnRestart( - input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } -): Promise { - await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, ({ sessionId }) => - restoreOneStructuredAgentSessionRead(input, sessionId) - ) +export function createStructuredAgentSessionStartupPass( + deps: StructuredAgentSessionStartupPassDeps +): StructuredAgentSessionStartupPass { + let running: Promise | null = null + let settledOpenOnce = false + const listed = (sessionId: string): boolean => + deps.store.listVisibleSessionIds().includes(sessionId) + const supportedRecord = (sessionId: string): AgentSessionRecord | null => { + const record = deps.store.getRecord(sessionId) + return record && deps.supportsRecord(record) ? record : null + } + + /** Phase 1's step: true when the saved copy was proven current and published. */ + const seedFromSavedStatus = (record: AgentSessionRecord): boolean => { + let saved: StructuredAgentSessionSavedStatus | null = null + try { + saved = deps.savedStatus?.read(record.sessionId) ?? null + } catch (error) { + // A saved copy that cannot be read is a miss, never a lost chat. + deps.onError(record.sessionId, error) + } + if ( + !saved || + saved.v !== STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION || + openOwesCommandSettlement(record) + ) { + return false + } + const position = probeJournalCursor(journalDirFor(deps.journalRoot, record), record.sessionId) + if (!position || !sameJournalCursor(saved.cursor, position)) { + return false + } + deps.seed(record.sessionId, saved) + return true + } + + const settleOpenConversations = async (): Promise => { + for (const sessionId of deps.sessions.keys()) { + await deps + .step(sessionId, async ({ session }) => { + if (session) { + await deps.settle(sessionId, session.journal) + } + }) + .catch((error: unknown) => deps.onError(sessionId, error)) + } + } + + /** Phase 2.3's step: open unless open, always settle, and close what only this pass wanted. */ + const openAndSettle = async (sessionId: string): Promise => { + await deps.step(sessionId, async ({ session, closeIfUnreached }) => { + const record = supportedRecord(sessionId) + if (!record || !listed(sessionId)) { + return + } + if (!session) { + const dir = journalDirFor(deps.journalRoot, record) + // No rows means no turn, and a chat with no turn has no row to publish. + if (!existsSync(journalDatabaseFile(dir)) && !findJournalFileFormatRemnant(dir)) { + return + } + } + const open = session ?? (await deps.open(sessionId)) + if (!open) { + return + } + await deps.settle(sessionId, open.journal) + if (!session) { + await closeIfUnreached() + } + }) + } + + const runOnce = async (priority?: StructuredAgentSessionStartupPriority): Promise => { + const startedAt = deps.now() + const misses: string[] = [] + let seeded = 0 + for (const sessionId of deps.store.listVisibleSessionIds()) { + await deps + .step(sessionId, async ({ session }) => { + const record = supportedRecord(sessionId) + if (session || !record || !listed(sessionId)) { + return + } + if (seedFromSavedStatus(record)) { + seeded += 1 + } else { + misses.push(sessionId) + } + }) + .catch((error: unknown) => deps.onError(sessionId, error)) + await yieldToEventLoop() + } + try { + deps.savedStatus?.prune((sessionId) => deps.store.getRecord(sessionId) !== null) + } catch (error) { + deps.onError('startup-pass', error) + } + console.info( + `[structured-agent-session] startup status: ${seeded} from saved copies, ${misses.length} to open, ${deps.now() - startedAt} ms` + ) + // Settled either way: a failed reconcile still leaves the opens and settlements below owed. + const reconciled = await deps.reconcile().then( + (refusal) => refusal === null, + (error: unknown) => { + deps.onError('startup-pass', error) + return false + } + ) + if (reconciled) { + // Every record, hidden ones too: an orphaned owner of a closed tab is still this host's. + for (const record of deps.store.listRecords()) { + if (structuredSessionRecoveryIsResolvable(record)) { + await deps + .resolveRecovery(record.sessionId) + .catch((error: unknown) => deps.onError(record.sessionId, error)) + } + } + } + await settleOpenConversations() + settledOpenOnce = true + const owed = deps.store + .listVisibleSessionIds() + .filter((sessionId) => structuredAgentSessionOwesSettlement(deps.store.getRecord(sessionId))) + const targets = orderStartupSessionIds( + [...new Set([...misses, ...owed])], + (sessionId) => deps.store.getRecord(sessionId)?.location.workspaceId, + priority + ) + for (const sessionId of targets) { + // Logged once, never retried by the pass: the first read reports it. + await openAndSettle(sessionId).catch((error: unknown) => deps.onError(sessionId, error)) + await yieldToEventLoop() + } + } + + return { + run: (priority) => { + running ??= runOnce(priority).catch((error: unknown) => { + running = null + throw error + }) + return running + }, + onReconciled: () => { + if (settledOpenOnce) { + void settleOpenConversations() + } + } + } +} + +/** The pass over a host's own deps: its store, journals, saved statuses and settlement. */ +export function createStructuredAgentSessionHostStartupPass( + deps: StructuredAgentSessionHostDeps, + wiring: Pick< + StructuredAgentSessionStartupPassDeps, + 'sessions' | 'step' | 'seed' | 'reconcile' | 'resolveRecovery' | 'open' | 'now' + > +): StructuredAgentSessionStartupPass { + return createStructuredAgentSessionStartupPass({ + ...wiring, + store: deps.store, + journalRoot: deps.journalRoot, + ...(deps.savedStatus ? { savedStatus: deps.savedStatus } : {}), + supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + settle: (sessionId, journal) => + retryLoadedStructuredAgentSessionSettlement({ deps, sessionId, journal, now: wiring.now }), + onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 884fba78a4ec..ef9ef80687d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -94,6 +94,8 @@ async function restartWithPersistedTurn(): Promise { const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + // A restart owes a row to every listed chat; an unlisted one waits for its first read. + await store.setSessionTabVisibility(SESSION, true) const body = hostTestMessage('persisted conversation') await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) await host.flushAllStreamedEvents() @@ -112,7 +114,7 @@ describe('structured session restart status publication', () => { it('projects the persisted turn of a session restored without a provider', async () => { const restarted = await restartWithPersistedTurn() - await restarted.restoreReadableSessions() + await restarted.restoreStartupSessions() const events: AgentSessionStatusEvent[] = [] restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) @@ -138,7 +140,7 @@ describe('structured session restart status publication', () => { restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) - await restarted.restoreReadableSessions() + await restarted.restoreStartupSessions() // The restore wiring publishes; without it this list never hears about the session at all. expect(events.at(-1)).toEqual({ 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 904be1e69777..021325dd0cea 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 @@ -11,14 +11,10 @@ // Retry, so the tab is worth publishing either way. import { adapterSupportsRecord } from './structured-agent-session-provider-support' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' -import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' /** Throws its refusal as the code itself. */ export async function revealStructuredAgentSession( @@ -49,34 +45,3 @@ export async function revealStructuredAgentSession( readable } } - -/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. */ -export function createStructuredAgentSessionHostRestore( - deps: StructuredAgentSessionHostDeps, - sessions: Map, - now: () => number, - wiring: Omit< - ConstructorParameters[0], - 'openDeps' | 'supportsRecord' | 'retrySettlement' - > -): { - restoreReadableSessions: (sessionIds?: readonly string[]) => Promise -} { - const restorer = new StructuredAgentSessionReadableRestorer({ - openDeps: deps, - supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - // Runs once the restorer indexed the conversation, so it retries into that one handle. - retrySettlement: (sessionId) => - retryPendingStructuredAgentSessionSettlement({ - deps, - sessionId, - openJournal: async () => sessions.get(sessionId)?.journal ?? null, - now - }), - ...wiring - }) - const gate = new StructuredAgentSessionRestartRestoreGate() - return { - restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index bdf46319bda2..d30af1c807a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -341,7 +341,7 @@ describe('settled attach retry', () => { probeOwner: async () => ({ outcome: 'pid-absent' }), now: () => NOW }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts index 8d19420feb43..a4e49f1d7d5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts @@ -1,3 +1,4 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' @@ -9,6 +10,16 @@ import { type DeadGenerationJournal } from './structured-agent-session-dead-generation-settlement' +/** Whether a restart or an exit left this record's turn owed a settlement. The one definition: + * every trigger that retries (the attach, a start, the startup pass) asks this. */ +export function structuredAgentSessionOwesSettlement( + record: AgentSessionRecord | null +): record is AgentSessionRecord & { + lease: AgentSessionRecord['lease'] & { settlementRetryId: string } +} { + return record?.lease.settlementRetryRequired === true && !!record.lease.settlementRetryId +} + /** Retries a durable provider-exit settlement against the conversation's own journal. The retry is * for an earlier child, so it writes at the record's fence and leaves the conversation — and any * message queued for the next child — alone. */ @@ -20,7 +31,7 @@ export async function retryPendingStructuredAgentSessionSettlement(input: { now: () => number }): Promise { const record = input.deps.store.getRecord(input.sessionId) - if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { + if (!structuredAgentSessionOwesSettlement(record)) { return true } let journal: AgentSessionJournal | null @@ -50,7 +61,7 @@ export async function retryLoadedStructuredAgentSessionSettlement(input: { now: () => number }): Promise { const record = input.deps.store.getRecord(input.sessionId) - if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { + if (!structuredAgentSessionOwesSettlement(record)) { return true } const { journal } = input diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass-test-rig.ts new file mode 100644 index 000000000000..fa5fdf8adc6b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass-test-rig.ts @@ -0,0 +1,261 @@ +// Real hosts over one real store, journals and saved-status file, booted in turn the way app runs +// follow each other, for the tests of what a restarted host owes each chat before anyone opens it. +// Every journal open calls the adapter's `historyFilePath` once, so that is the open counter, and +// holding it holds the open. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AgentSessionSavedStatusStore } from '../../runtime/agent-session-saved-status-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_LOCATION, + HOST_TEST_NOW, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +export const STARTUP_CALLER = { callerKey: 'client-1' } + +export type StartupRig = { + root: string + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + savedStatus: AgentSessionSavedStatusStore + acquire: Mock + dispatch: Mock + /** Called once per journal open, with the session id; replace its implementation to hold one. */ + historyFilePath: Mock<(sessionId: string) => Promise> + probeOwner: Mock<(record: AgentSessionRecord) => Promise> + statusEvents: AgentSessionStatusEvent[] + sink: { publish: Mock; forget: Mock } + /** Creates the chat (or attaches it again at its fence), lists its tab unless told not to, and + * sends `message` when one is given, waiting for its dispatch. */ + chat: ( + sessionId: string, + options?: { workspaceId?: string; listed?: boolean; message?: string } + ) => Promise + opensOf: (sessionId: string) => number + /** A clean quit: teardown settles and saves, then the saved-status file closes. */ + quit: () => Promise + /** A crash: nothing settles or flushes; the process's timers and handles simply stop. */ + crash: () => Promise + /** A new app run over the same files; the counters and status stream start empty. */ + boot: (deps?: Partial) => Promise + dispose: () => Promise +} + +export function startupAttachParams( + sessionId: string, + workspaceId: string, + fence: number | null +): AgentSessionAttachParams { + return hostTestAttachParams(fence, { + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + }, + location: { ...HOST_TEST_LOCATION, workspaceId }, + providerHandle: { kind: 'codex', threadId: `thread-${sessionId}` } + }) +} + +export async function createStartupRig(): Promise { + resetHostTestOperationIds() + const root = await mkdtemp(join(tmpdir(), 'orca-startup-pass-')) + const directory = join(root, 'store') + const statusEvents: AgentSessionStatusEvent[] = [] + const sink = { publish: vi.fn(), forget: vi.fn() } + const historyFilePath = vi.fn(async (_sessionId: string): Promise => null) + const probeOwner: StartupRig['probeOwner'] = vi.fn(async () => ({ outcome: 'pid-absent' })) + let ordinal = 0 + const acquire: StartupRig['acquire'] = vi.fn(async ({ fence, spawnToken, identity }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${identity.sessionId}-${fence}`, + handle: + identity.providerHandle.kind === 'codex' + ? { provider: 'codex' as const, threadId: identity.providerHandle.threadId } + : { provider: 'codex' as const, threadId: `thread-${identity.sessionId}` }, + origin: rig.store.getRecord(identity.sessionId)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: HOST_TEST_NOW + } + })) + const dispatch: StartupRig['dispatch'] = vi.fn(async (input) => { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: `thread-${input.sessionId}`, + turnId: `turn-${ordinal}`, + ordinal + } + } + }) + const openAll: { store: AgentSessionRecordStore; savedStatus: AgentSessionSavedStatusStore }[] = + [] + const makeHost = ( + store: AgentSessionRecordStore, + savedStatus: AgentSessionSavedStatusStore, + deps: Partial + ) => + new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async ({ commit }) => commit(), + setOption: async () => undefined, + historyFilePath: ({ identity }) => historyFilePath(identity.sessionId) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + probeOwner, + now: () => HOST_TEST_NOW, + statusSink: sink, + savedStatus, + ...deps + }) + const open = async (deps: Partial) => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const savedStatus = AgentSessionSavedStatusStore.open(root) + openAll.push({ store, savedStatus }) + const host = makeHost(store, savedStatus, deps) + host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return { store, savedStatus, host } + } + const first = await open({}) + const rig: StartupRig = { + root, + ...first, + acquire, + dispatch, + historyFilePath, + probeOwner, + statusEvents, + sink, + chat: async (sessionId, options = {}) => { + const workspaceId = options.workspaceId ?? 'workspace-1' + const attach = () => + rig.host.attach( + STARTUP_CALLER, + startupAttachParams( + sessionId, + workspaceId, + rig.store.getRecord(sessionId)?.lease.runtimeFence ?? null + ) + ) + let attached = await attach() + if (!attached.ok && attached.refusal.code === 'agent_session_checkpoint_stale') { + attached = await attach() + } + if (!attached.ok) { + throw new Error(`attach refused: ${attached.refusal.code} ${attached.refusal.message}`) + } + if (options.listed !== false) { + await rig.store.setSessionTabVisibility(sessionId, true) + } + if (options.message === undefined) { + return + } + const dispatched = dispatch.mock.calls.length + const body = hostTestMessage(options.message) + const sent = await rig.host.send(STARTUP_CALLER, { + body, + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: attached.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + }) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await vi.waitFor(() => expect(dispatch.mock.calls.length).toBeGreaterThan(dispatched)) + }, + opensOf: (sessionId) => historyFilePath.mock.calls.filter(([id]) => id === sessionId).length, + quit: async () => { + await rig.host.flushAllStreamedEvents() + rig.savedStatus.close() + }, + crash: async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private collaborators, reached only to stop them as a dead process would. + const internals = rig.host as unknown as { + runtimeState: { stopLeaseRenewal: () => void } + lifetime: { dispose: () => void } + sessions: Map Promise } }> + } + internals.runtimeState.stopLeaseRenewal() + internals.lifetime.dispose() + for (const session of internals.sessions.values()) { + await session.journal.close() + } + internals.sessions.clear() + }, + boot: async (deps = {}) => { + // What the new run saw, and nothing the previous one did. + historyFilePath.mockClear() + sink.publish.mockClear() + sink.forget.mockClear() + statusEvents.length = 0 + const next = await open(deps) + rig.store = next.store + rig.savedStatus = next.savedStatus + rig.host = next.host + return next.host + }, + dispose: async () => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + openAll.forEach(({ savedStatus }) => savedStatus.close()) + await rm(root, { recursive: true, force: true }) + } + } + return rig +} + +/** The newest row the status stream carried for a session. */ +export function latestStatus(rig: StartupRig, sessionId: string) { + for (const event of rig.statusEvents.toReversed()) { + if (event.type === 'status' && event.session.sessionId === sessionId) { + return event.session + } + if (event.type === 'snapshot') { + const found = event.sessions.find((session) => session.sessionId === sessionId) + if (found) { + return found + } + } + } + return undefined +} + +/** Whether the host holds this conversation open right now. */ +export function isOpen(rig: StartupRig, sessionId: string): boolean { + return rig.host.hasSession(sessionId) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts new file mode 100644 index 000000000000..5aa605781974 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts @@ -0,0 +1,309 @@ +// What a restarted host owes each listed chat before anyone opens it: a status row from its saved +// copy when the journal still stands where that copy was taken, and otherwise one open, settled and +// closed again, off every reader's path. + +import { existsSync } from 'node:fs' +import { rm, writeFile } from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION } from '../../../shared/structured-agent-session-saved-status' +import { AgentSessionSavedStatusStore } from '../../runtime/agent-session-saved-status-store' +import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { HOST_TEST_NOW } from './structured-agent-session-host-test-data' +import { + createStartupRig, + isOpen, + latestStatus, + type StartupRig +} from './structured-agent-session-startup-pass-test-rig' + +let rig: StartupRig + +type HostLock = { serialize: (id: string, task: () => Promise) => Promise } + +beforeEach(async () => { + rig = await createStartupRig() +}) + +afterEach(async () => { + await rig.dispose() +}) + +function journalDir(sessionId: string): string { + return journalDirectoryFor(rig.root, { workspaceId: 'workspace-1', sessionId }) +} + +/** A second run's send, then a crash with the turn still running and its child alive. */ +async function crashMidTurn(sessionId: string, text: string): Promise { + const host = await rig.boot() + await host.restoreStartupSessions() + await rig.chat(sessionId, { message: text }) + // The flush timer fired: whatever the running turn left in the saved copy is on disk. + rig.savedStatus.flush() + await rig.crash() +} + +describe('listing from a saved status', () => { + it('lists a settled chat from its saved status without opening its journal (C1)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(0) + expect(isOpen(rig, 'session-a')).toBe(false) + expect(latestStatus(rig, 'session-a')).toMatchObject({ + sessionId: 'session-a', + workspaceId: 'workspace-1', + status: 'idle', + latestPrompt: 'first question' + }) + }) + + it('opens a chat whose saved copy an older projection version wrote (C3)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const stale = AgentSessionSavedStatusStore.open(rig.root) + const saved = stale.read('session-a') + expect(saved?.v).toBe(STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION) + stale.record('session-a', { ...saved!, v: saved!.v - 1 }) + stale.close() + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(1) + }) + + it('shows a model switched while idle, read from the record rather than the saved copy (C4)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const host = await rig.boot() + const record = rig.store.getRecord('session-a') + await rig.store.replaceSessionOptions({ + sessionId: 'session-a', + fence: record?.lease.runtimeFence ?? 0, + options: { model: 'gpt-switched' }, + now: HOST_TEST_NOW + }) + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(0) + expect(latestStatus(rig, 'session-a')).toMatchObject({ model: 'gpt-switched' }) + }) + + it('serves a chat that never had a turn from its saved copy, as its open would (C5)', async () => { + await rig.chat('session-a') + await rig.quit() + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(0) + expect(latestStatus(rig, 'session-a')).toMatchObject({ status: null, latestPrompt: '' }) + }) + + it('publishes the saved row as a replay: no rename edge, no start, no completion (C6)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const onSessionStatusChanged = vi.fn() + const completions = vi.fn() + const host = await rig.boot({ onSessionStatusChanged }) + host.subscribeTurnCompletions({ id: 'completions', emit: completions }) + + await host.restoreStartupSessions() + + const seen = onSessionStatusChanged.mock.calls.filter( + ([summary]) => summary.sessionId === 'session-a' + ) + expect(seen).toHaveLength(1) + expect(seen[0]?.[1]).toEqual({ replay: true }) + expect(seen[0]?.[0].hostExecutionPhase).toBeUndefined() + expect(completions).not.toHaveBeenCalled() + }) + + it('never lists a crashed turn from the settled copy before it (C2)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + await crashMidTurn('session-a', 'second question') + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(1) + expect(latestStatus(rig, 'session-a')).toMatchObject({ + status: 'idle', + latestPrompt: 'second question' + }) + expect(rig.store.getRecord('session-a')?.lease.settlementRetryRequired).toBeUndefined() + }) + + it('lists what a quit mid-turn settled, never the finished turn before it (C10)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const second = await rig.boot() + await second.restoreStartupSessions() + await rig.chat('session-a', { message: 'second question' }) + await rig.quit() + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(latestStatus(rig, 'session-a')).toMatchObject({ latestPrompt: 'second question' }) + }) + + it('drops saved copies of chats that no longer exist (C11)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const orphan = AgentSessionSavedStatusStore.open(rig.root) + orphan.record('session-gone', { ...orphan.read('session-a')! }) + orphan.close() + const host = await rig.boot() + + await host.restoreStartupSessions() + rig.savedStatus.flush() + + expect(rig.savedStatus.read('session-gone')).toBeNull() + expect(rig.savedStatus.read('session-a')).not.toBeNull() + }) + + it('keeps status and reads working when the saved-status store throws (C12)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const broken = { + read: () => { + throw new Error('saved status unreadable') + }, + record: () => { + throw new Error('saved status unwritable') + }, + prune: () => { + throw new Error('saved status unprunable') + } + } + const host = await rig.boot({ savedStatus: broken }) + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(1) + expect(latestStatus(rig, 'session-a')).toMatchObject({ latestPrompt: 'first question' }) + const page = await host.history({ sessionId: 'session-a', direction: 'tail' }) + expect(page.ok).toBe(true) + }) +}) + +describe('a saved row is what the open would publish (C9b)', () => { + it('equals the row a real first open publishes for a settled chat', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const seededHost = await rig.boot() + await seededHost.restoreStartupSessions() + const seeded = latestStatus(rig, 'session-a') + expect(rig.opensOf('session-a')).toBe(0) + await rig.quit() + const openedHost = await rig.boot({ savedStatus: undefined }) + + await openedHost.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(1) + expect(latestStatus(rig, 'session-a')).toEqual(seeded) + }) + + it('opens a chat whose last run left a compaction prepared, and settles it', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + const host = await rig.boot() + const fence = rig.store.getRecord('session-a')?.lease.runtimeFence ?? 0 + await rig.store.setConversationCommand('session-a', fence, { + command: 'compact', + state: 'unknown', + phase: 'prepared', + runtimeFence: fence, + operationId: 'compact-op-1', + callerKey: 'client-1' + }) + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(1) + expect(rig.store.getRecord('session-a')?.conversationCommand?.phase).toBe('committed') + }) +}) + +describe('what the pass opens', () => { + it('keeps a chat with an unusable journal listed, opens it once, and refuses its read (L2, C8)', async () => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + await writeFile(journalDatabaseFile(journalDir('session-a')), Buffer.alloc(8192, 7)) + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(rig.opensOf('session-a')).toBe(1) + expect(latestStatus(rig, 'session-a')).toBeUndefined() + expect(host.listPersistedSessionTabs(rig.store.listVisibleSessionIds())).toEqual([ + { sessionId: 'session-a', workspaceId: 'workspace-1', agent: 'codex' } + ]) + await expect(host.history({ sessionId: 'session-a', direction: 'tail' })).rejects.toThrow( + /^agent_session_journal_unreadable$/ + ) + }) + + it('founds no journal for a listed chat that has none, and it still reads (L3)', async () => { + await rig.chat('session-a') + await rig.quit() + await rm(journalDir('session-a'), { recursive: true, force: true }) + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(existsSync(journalDatabaseFile(journalDir('session-a')))).toBe(false) + expect(rig.opensOf('session-a')).toBe(0) + const page = await host.history({ sessionId: 'session-a', direction: 'tail' }) + expect(page.ok && page.page.items).toEqual([]) + }) + + it('neither lists nor opens a chat whose tab closes before its step (L7)', async () => { + await rig.chat('session-a', { message: 'first' }) + await rig.chat('session-b', { message: 'second' }) + await rig.quit() + const host = await rig.boot() + // Queued on the chat's own lock ahead of the pass, as a close landing mid-pass would be. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private per-session lock, reached to order a close ahead of the pass. + const { serialize } = host as unknown as HostLock + const closed = serialize('session-b', () => + rig.store.setSessionTabVisibility('session-b', false) + ) + + await host.restoreStartupSessions() + await closed + + expect(latestStatus(rig, 'session-a')).toMatchObject({ latestPrompt: 'first' }) + expect(latestStatus(rig, 'session-b')).toBeUndefined() + expect(rig.opensOf('session-b')).toBe(0) + }) + + it('opens no chat whose tab closed between the status check and its open (L7)', async () => { + await rig.chat('session-a', { message: 'first' }) + await rig.chat('session-b', { message: 'second' }) + await rig.crash() + const reconcile = Promise.withResolvers() + const probing = Promise.withResolvers() + rig.probeOwner.mockImplementation(async () => { + probing.resolve() + await reconcile.promise + return { outcome: 'pid-absent' } + }) + const host = await rig.boot() + + const pass = host.restoreStartupSessions() + await probing.promise + await rig.store.setSessionTabVisibility('session-b', false) + reconcile.resolve() + await pass + + expect(rig.opensOf('session-a')).toBe(1) + expect(rig.opensOf('session-b')).toBe(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts new file mode 100644 index 000000000000..d0ca18ba05b3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts @@ -0,0 +1,247 @@ +// The startup pass's second phase: after reconcile, what a restart left owed is settled for every +// listed chat and every open conversation, one chat at a time, without holding any reader behind +// more than the chat in progress, and without leaving open what only the pass wanted open. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION } from '../../../shared/structured-agent-session-saved-status' +import { probeJournalCursor } from '../agent-session-journal/journal-cursor-probe' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { + createStartupRig, + isOpen, + type StartupRig +} from './structured-agent-session-startup-pass-test-rig' + +let rig: StartupRig + +beforeEach(async () => { + rig = await createStartupRig() +}) + +afterEach(async () => { + await rig.dispose() +}) + +/** Holds every journal open while `holding`, releasable one session at a time. */ +function gateOpens() { + const held = new Map void>() + const gate = { holding: true, held, release: (sessionId: string) => held.get(sessionId)?.() } + rig.historyFilePath.mockImplementation(async (sessionId) => { + if (gate.holding) { + const opened = Promise.withResolvers() + held.set(sessionId, opened.resolve) + await opened.promise + } + return null + }) + return gate +} + +const owesSettlement = (sessionId: string) => + rig.store.getRecord(sessionId)?.lease.settlementRetryRequired === true + +describe('settling what a restart left owed', () => { + it('reads a restart-cut turn before reconcile, and settles it once reconcile lands (L6)', async () => { + // Hidden: no tab, so only the pass's settlement of open conversations can reach it. + await rig.chat('session-h', { listed: false, message: 'cut off' }) + await rig.crash() + const reconcile = Promise.withResolvers() + const probing = Promise.withResolvers() + rig.probeOwner.mockImplementation(async () => { + probing.resolve() + await reconcile.promise + return { outcome: 'pid-absent' } + }) + const host = await rig.boot() + const acquired = rig.acquire.mock.calls.length + + const pass = host.restoreStartupSessions() + await probing.promise + // A read waits on no reconcile. + const page = await host.history({ sessionId: 'session-h', direction: 'tail' }) + expect(page.ok).toBe(true) + reconcile.resolve() + await pass + + expect(owesSettlement('session-h')).toBe(false) + expect(rig.store.getRecord('session-h')?.lease.settlementRetryId).toBeUndefined() + expect(rig.acquire.mock.calls.length).toBe(acquired) + }) + + it('settles a listed chat whose child outlived a crash even when its saved status matched (L18)', async () => { + await rig.chat('session-a', { message: 'one' }) + await rig.chat('session-b', { message: 'two' }) + await rig.quit() + const second = await rig.boot() + await second.restoreStartupSessions() + await rig.chat('session-a') + rig.savedStatus.flush() + await rig.crash() + const dir = journalDirectoryFor(rig.root, { + workspaceId: 'workspace-1', + sessionId: 'session-a' + }) + const saved = rig.savedStatus.read('session-a') + expect(saved?.v).toBe(STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION) + expect(saved?.cursor).toEqual(probeJournalCursor(dir, 'session-a')) + const host = await rig.boot() + + await host.restoreStartupSessions() + + expect(owesSettlement('session-a')).toBe(false) + expect(rig.opensOf('session-a')).toBe(1) + expect(rig.opensOf('session-b')).toBe(0) + // Settling an idle chat changes nothing it shows: the seeded row stands (C9b). + const rows = rig.statusEvents.flatMap((event) => + event.type === 'status' && event.session.sessionId === 'session-a' ? [event.session] : [] + ) + expect(rows.length).toBeGreaterThan(0) + rows.forEach((row) => expect(row).toEqual(rows[0])) + }) + + it('settles open conversations again after a later reconcile succeeds (L19)', async () => { + await rig.chat('session-a', { message: 'cut off' }) + await rig.crash() + let reconcileFails = true + rig.probeOwner.mockImplementation(async () => { + if (reconcileFails) { + throw new Error('probe unavailable') + } + return { outcome: 'pid-absent' } + }) + const host = await rig.boot() + await host.restoreStartupSessions() + await host.history({ sessionId: 'session-a', direction: 'tail' }) + expect(rig.store.getRecord('session-a')?.lease.unreconciled).toBe(true) + + reconcileFails = false + await host.reconcileRestartLeases() + + await vi.waitFor(() => expect(owesSettlement('session-a')).toBe(false)) + }) +}) + +describe('one chat at a time, readers first', () => { + it('opens by tier, lets a read of another chat through, and settles a chat a read opened (L17)', async () => { + await rig.chat('session-r', { workspaceId: 'ws-rest', message: 'rest' }) + await rig.chat('session-v', { workspaceId: 'ws-visible', message: 'visible' }) + await rig.chat('session-x', { workspaceId: 'ws-active', message: 'active' }) + await rig.crash() + const host = await rig.boot() + const gate = gateOpens() + + const pass = host.restoreStartupSessions({ + activeWorkspaceId: 'ws-active', + visibleWorkspaceIds: new Set(['ws-visible']) + }) + await vi.waitFor(() => expect([...gate.held.keys()]).toEqual(['session-x'])) + gate.holding = false + // The pass is inside session-x's open; a read of a later chat does not wait for it. + const page = await host.history({ sessionId: 'session-r', direction: 'tail' }) + expect(page.ok).toBe(true) + expect(owesSettlement('session-r')).toBe(true) + gate.release('session-x') + await pass + + expect(rig.historyFilePath.mock.calls.map(([sessionId]) => sessionId)).toEqual([ + 'session-x', + 'session-r', + 'session-v' + ]) + expect(owesSettlement('session-r')).toBe(false) + expect(owesSettlement('session-v')).toBe(false) + expect(owesSettlement('session-x')).toBe(false) + }) + + it('opens a chat once when a read queues behind or ahead of the pass (L5, L20)', async () => { + await rig.chat('session-x', { message: 'first' }) + await rig.chat('session-y', { message: 'second' }) + await rig.quit() + const host = await rig.boot({ savedStatus: undefined }) + const gate = gateOpens() + + const pass = host.restoreStartupSessions() + await vi.waitFor(() => expect([...gate.held.keys()]).toEqual(['session-x'])) + const reads = [ + host.history({ sessionId: 'session-y', direction: 'tail' }), + host.history({ sessionId: 'session-y', direction: 'tail' }) + ] + await vi.waitFor(() => expect(gate.held.has('session-y')).toBe(true)) + gate.release('session-x') + gate.holding = false + gate.release('session-y') + await Promise.all([pass, ...reads]) + + expect(rig.opensOf('session-y')).toBe(1) + expect(isOpen(rig, 'session-y')).toBe(true) + }) + + it('closes what it opened only for status, never what a reader reached (L21)', async () => { + await rig.chat('session-p', { message: 'pass only' }) + await rig.chat('session-r', { message: 'reached' }) + await rig.chat('session-u', { message: 'user first' }) + await rig.quit() + const host = await rig.boot({ savedStatus: undefined }) + await host.history({ sessionId: 'session-u', direction: 'tail' }) + const gate = gateOpens() + + const pass = host.restoreStartupSessions() + await vi.waitFor(() => expect(gate.held.has('session-p')).toBe(true)) + gate.release('session-p') + await vi.waitFor(() => expect(gate.held.has('session-r')).toBe(true)) + const read = host.history({ sessionId: 'session-r', direction: 'tail' }) + gate.holding = false + gate.release('session-r') + await Promise.all([pass, read]) + + expect(isOpen(rig, 'session-p')).toBe(false) + expect(isOpen(rig, 'session-r')).toBe(true) + expect(isOpen(rig, 'session-u')).toBe(true) + expect(rig.sink.publish).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-p', latestPrompt: 'pass only' }), + expect.anything() + ) + expect(rig.sink.forget).not.toHaveBeenCalled() + }) +}) + +describe('recovery at boot', () => { + it('stops a surviving owner of every resolvable record, hidden or listed, and no other (L8)', async () => { + await rig.chat('session-r', { message: 'released' }) + await rig.quit() + await rig.boot() + const pids: Record = { 'session-h': 5001, 'session-l': 5002, 'session-c': 5003 } + const acquire = rig.acquire.getMockImplementation()! + rig.acquire.mockImplementation(async (input) => { + const acquired = await acquire(input) + const pid = pids[input.identity.sessionId] ?? acquired.process.pid + return { ...acquired, process: { ...acquired.process, pid } } + }) + await rig.chat('session-h', { listed: false }) + await rig.chat('session-l') + await rig.chat('session-c') + await rig.store.transitionHandoff('session-c', (record) => ({ + ...record, + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + })) + await rig.crash() + const alive = new Set(Object.values(pids)) + rig.probeOwner.mockImplementation(async (record) => + alive.has(record.lease.ownerProcess?.pid ?? -1) + ? { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + : { outcome: 'pid-absent' } + ) + const stopOwnerProcess = vi.fn((pid: number) => { + alive.delete(pid) + }) + const host = await rig.boot({ stopOwnerProcess }) + + await host.restoreStartupSessions() + + const stopped = new Set(stopOwnerProcess.mock.calls.map(([pid]) => pid)) + expect(stopped).toEqual(new Set([5001, 5002])) + expect(rig.store.getRecord('session-h')?.lease).toMatchObject({ claimStatus: 'released' }) + expect(rig.store.getRecord('session-r')?.lease).toMatchObject({ claimStatus: 'released' }) + expect(rig.store.getRecord('session-c')?.lease.claimStatus).toBe('conflicted') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-equality.ts new file mode 100644 index 000000000000..6efb7c02fcdb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-equality.ts @@ -0,0 +1,52 @@ +// When two status projections of one session are the same row to a reader, so a re-projection +// that changes nothing a list shows is not re-sent. + +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' +import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' +import { + agentSessionBackgroundTasksEqual, + type AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' + +export function structuredAgentSessionSummariesEqual( + a: AgentSessionStatusSummary, + b: AgentSessionStatusSummary +): boolean { + return ( + a.workspaceId === b.workspaceId && + a.agent === b.agent && + a.status === b.status && + a.hostExecutionOwned === b.hostExecutionOwned && + a.hostExecutionPhase === b.hostExecutionPhase && + a.rewindBlockedReason === b.rewindBlockedReason && + // A moved state clock changes ranking; row activity alone, including a subagent's, does not. + // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, + // and so does one live child work holds open: readers take each publish as its evidence. + a.statusStartedAt === b.statusStartedAt && + (a.status !== 'idle' || + a.updatedAt === b.updatedAt || + (a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) && + a.latestPrompt === b.latestPrompt && + a.model === b.model && + a.toolName === b.toolName && + a.toolInput === b.toolInput && + a.lastAssistantMessage === b.lastAssistantMessage && + a.turnOutcome === b.turnOutcome && + agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && + agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) + ) +} + +function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean { + return ( + summary.status === 'idle' && + isAgentStatusHeldOpenByChildWork( + structuredAgentSessionAgentStatus({ + status: summary.status, + backgroundTasks: summary.backgroundTasks, + turnOutcome: summary.turnOutcome + }) + ) + ) +} 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 92161a5bca07..3094bc73206a 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 @@ -7,23 +7,29 @@ // // The last projection is kept after the session's provider child is evicted: an idle session is // still idle without a process, and a renderer that reloads must not lose every settled row until -// each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions -// republishes them. +// each chat is reopened. A settled projection is also saved with the journal position it was +// computed at, so a restart republishes a listed chat from that saved copy when its journal still +// stands there, and from the journal otherwise. -import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' -import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' -import { - agentSessionBackgroundTasksEqual, - type AgentSessionBackgroundTaskState, - type AgentSessionStatusEvent, - type AgentSessionStatusSummary +import type { + AgentSessionBackgroundTaskState, + AgentSessionStatusEvent, + AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import { + projectStructuredAgentSessionStatusSummary, + type StructuredAgentSessionStatusProjection +} from '../../../shared/structured-agent-session-projection' +import { + isSavableStructuredAgentSessionProjection, + STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + type StructuredAgentSessionSavedStatus +} from '../../../shared/structured-agent-session-saved-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionSummariesEqual as summariesEqual } from './structured-agent-session-status-equality' import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' import { @@ -59,45 +65,8 @@ export type StructuredAgentSessionStatusFeedDeps = { readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined /** The session's agent proved a start: its row's phase became `ready`. */ onAgentStarted?: (sessionId: string) => void -} - -function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { - return ( - a.workspaceId === b.workspaceId && - a.agent === b.agent && - a.status === b.status && - a.hostExecutionOwned === b.hostExecutionOwned && - a.hostExecutionPhase === b.hostExecutionPhase && - a.rewindBlockedReason === b.rewindBlockedReason && - // A moved state clock changes ranking; row activity alone, including a subagent's, does not. - // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, - // and so does one live child work holds open: readers take each publish as its evidence. - a.statusStartedAt === b.statusStartedAt && - (a.status !== 'idle' || - a.updatedAt === b.updatedAt || - (a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) && - a.latestPrompt === b.latestPrompt && - a.model === b.model && - a.toolName === b.toolName && - a.toolInput === b.toolInput && - a.lastAssistantMessage === b.lastAssistantMessage && - a.turnOutcome === b.turnOutcome && - agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && - agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) - ) -} - -function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean { - return ( - summary.status === 'idle' && - isAgentStatusHeldOpenByChildWork( - structuredAgentSessionAgentStatus({ - status: summary.status, - backgroundTasks: summary.backgroundTasks, - turnOutcome: summary.turnOutcome - }) - ) - ) + /** A settled projection and the journal position it was computed at, for the next restart. */ + saveProjection?: (sessionId: string, saved: StructuredAgentSessionSavedStatus) => void } /** Wire the host's own deps into a feed; keeps the host at one call site. @@ -115,6 +84,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { } onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink + savedStatus?: { record: NonNullable } } onAgentStarted?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { @@ -127,6 +97,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. statusSink: () => args.deps().statusSink, + saveProjection: (sessionId, saved) => args.deps().savedStatus?.record(sessionId, saved), ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}) }) } @@ -227,8 +198,28 @@ export class StructuredAgentSessionStatusFeed { if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { this.deps.onAgentStarted?.(sessionId) } + this.notify(summary, options?.replay === true) + } + + /** A restarted host's row for a chat it has not opened, from a saved copy the caller proved + * current. A replay: it is state the host already had, so no rename or restart edge fires. */ + seedRestored(sessionId: string, saved: StructuredAgentSessionSavedStatus): boolean { + const record = this.deps.getRecord(sessionId) + if (!record || this.deps.sessions.has(sessionId) || this.published.has(sessionId)) { + return false + } + const target = { params: { location: record.location, provider: record.provider } } + const summary = this.compose(sessionId, target, saved.projection, saved.lastActivityAt) + this.published.set(sessionId, summary) + this.sink(summary, record.location) + this.broadcast({ type: 'status', session: summary }) + this.notify(summary, true) + return true + } + + private notify(summary: AgentSessionStatusSummary, replay: boolean): void { try { - this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) + this.deps.onStatusChanged?.(summary, { replay }) } catch (error) { // An observer must never cost the subscribers their status event. console.warn('[structured-session-status] status observer failed', error) @@ -268,7 +259,36 @@ export class StructuredAgentSessionStatusFeed { ) } this.journalProjections.set(journal, projection) + if (!readOnly && isSavableStructuredAgentSessionProjection(projection.summary)) { + this.save(sessionId, { + v: STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + cursor, + projection: projection.summary, + lastActivityAt: journal.lastActivityAt() + }) + } } + return this.compose(sessionId, session, projection.summary, journal.lastActivityAt(), record) + } + + /** Best-effort: a failing save costs the next restart one journal open, never this publish. */ + private save(sessionId: string, saved: StructuredAgentSessionSavedStatus): void { + try { + this.deps.saveProjection?.(sessionId, saved) + } catch (error) { + console.warn('[structured-session-status] saving the settled status failed', error) + } + } + + /** The one composer for a journal projection and a saved one: record-side fields are read here, + * never saved, so a model switched while idle cannot leave two copies that disagree. */ + private compose( + sessionId: string, + session: Pick, + projection: StructuredAgentSessionStatusProjection, + lastActivityAt: number, + record = this.deps.getRecord(sessionId) + ): AgentSessionStatusSummary { const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. @@ -286,14 +306,14 @@ export class StructuredAgentSessionStatusFeed { ...(session.child ? { hostExecutionOwned: true as const, hostExecutionPhase: session.child.phase } : {}), - ...projection.summary, + ...projection, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), ...(backgroundTasks && backgroundTasks.length > 0 ? { backgroundTasks } : {}), ...(providerSession ? { providerSession } : {}), - updatedAt: journal.lastActivityAt() || this.deps.now() + updatedAt: lastActivityAt || this.deps.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 2989fb1d3e5a..dfeb93fc7099 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -382,6 +382,8 @@ describe('a session with a turn in flight', () => { describe('startup', () => { it('settles an idle absent owner without chat pollution and resumes the same provider identity', async () => { await attach() + // Listed: the startup pass settles what a listed chat owes. + await store.setSessionTabVisibility(SESSION, true) const beforeRestart = store.getRecord(SESSION) host['runtimeState'].stopLeaseRenewal() host['lifetime'].dispose() @@ -390,7 +392,7 @@ describe('startup', () => { store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() + await host.restoreStartupSessions() const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( @@ -413,11 +415,11 @@ describe('startup', () => { await attach() await reboot() - await host.restoreReadableSessions() + await host.restoreStartupSessions() // The record is readable — the tab comes back, history answers — and nothing is running. expect(acquire).not.toHaveBeenCalled() - expect(host.listSessionTabs()).toEqual([ + expect(host.listPersistedSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) @@ -427,7 +429,7 @@ describe('startup', () => { it('gives the child back for work, never for a read', async () => { await attach() await reboot() - await host.restoreReadableSessions() + await host.restoreStartupSessions() const unsubscribe = await host.subscribe({ id: 'viewer-1', 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 842ddc81e24a..2a5168fce461 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 @@ -105,7 +105,10 @@ function wedgedRecord(overrides: WedgeOverrides): PersistedAgentSessionRecord { } } -async function seedStore(record: PersistedAgentSessionRecord): Promise { +async function seedStore( + record: PersistedAgentSessionRecord, + options: { listed?: boolean } = {} +): Promise { const directory = join(root, 'store') await mkdir(directory, { recursive: true }) await writeFile( @@ -116,7 +119,8 @@ async function seedStore(record: PersistedAgentSessionRecord): Promise { records: { [record.sessionId]: record }, operations: {}, retiredClaimKeys: [], - unusableRecords: {} + unusableRecords: {}, + ...(options.listed ? { visibleSessionIds: [record.sessionId] } : {}) }), 'utf-8' ) @@ -254,13 +258,15 @@ describe('already-wedged profiles become usable on load', () => { } ] } - await seedStore(providerRecord) + await seedStore(providerRecord, { listed: true }) await seedRunningTurn(provider) openHost() - await host.restoreReadableSessions() + await host.restoreStartupSessions() - expect(host.hasSession(SESSION)).toBe(true) + // Opened only to settle its status, so the pass closed it again; a read reopens it. + expect(host.hasSession(SESSION)).toBe(false) + await host.history({ sessionId: SESSION, direction: 'tail' }) const firstCursor = restoredJournal().cursor() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -277,7 +283,8 @@ describe('already-wedged profiles become usable on load', () => { hostId: 'local' }) openHost() - await host.restoreReadableSessions() + await host.restoreStartupSessions() + await host.history({ sessionId: SESSION, direction: 'tail' }) expect(restoredJournal().cursor()).toEqual(firstCursor) expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -407,7 +414,7 @@ describe('already-wedged profiles become usable on load', () => { ) openHost() - await host.restoreReadableSessions() + await host.restoreStartupSessions() const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -436,7 +443,7 @@ describe('already-wedged profiles become usable on load', () => { probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'conflicted', @@ -451,7 +458,7 @@ describe('already-wedged profiles become usable on load', () => { await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' })) openHost() - await host.restoreReadableSessions() + await host.restoreStartupSessions() const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -484,7 +491,7 @@ describe('already-wedged profiles become usable on load', () => { } }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -525,7 +532,7 @@ describe('already-wedged profiles become usable on load', () => { stopOwnerProcess: (pid) => order.push(`stop:${pid}`) }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null @@ -543,7 +550,7 @@ describe('already-wedged profiles become usable on load', () => { }) ) openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no answer' }) }) - await host.restoreReadableSessions() + await host.restoreStartupSessions() const refused = await host.attach(CALLER, hostTestAttachParams(13)) diff --git a/src/main/runtime/agent-session-saved-status-store.test.ts b/src/main/runtime/agent-session-saved-status-store.test.ts new file mode 100644 index 000000000000..dc839c70deb9 --- /dev/null +++ b/src/main/runtime/agent-session-saved-status-store.test.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + type StructuredAgentSessionSavedStatus +} from '../../shared/structured-agent-session-saved-status' +import Database from '../sqlite/sync-database' +import { + AGENT_SESSION_SAVED_STATUS_FILE, + AgentSessionSavedStatusStore +} from './agent-session-saved-status-store' + +let dirs: string[] = [] +let stores: AgentSessionSavedStatusStore[] = [] + +afterEach(() => { + stores.forEach((store) => store.close()) + stores = [] + dirs.forEach((dir) => rmSync(dir, { recursive: true, force: true })) + dirs = [] + vi.restoreAllMocks() +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-saved-status-')) + dirs.push(dir) + return dir +} + +function open(dir: string): AgentSessionSavedStatusStore { + const store = AgentSessionSavedStatusStore.open(dir) + stores.push(store) + return store +} + +function saved(sequence: number, latestPrompt = 'hello'): StructuredAgentSessionSavedStatus { + return { + v: STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + cursor: { epoch: 'epoch-a', sequence }, + projection: { status: 'idle', latestPrompt, lastAssistantMessage: 'done' }, + lastActivityAt: 1_000 + sequence + } +} + +/** Holds the file's write lock from another thread. Once `flushing` is signalled it keeps the lock + * a further 300 ms and commits: a flush that waits on the lock succeeds, one that skips does not. */ +async function holdWriteLock(path: string) { + const signal = new Int32Array(new SharedArrayBuffer(4)) + const worker = new Worker( + `const { parentPort, workerData } = require('node:worker_threads') + const { DatabaseSync } = require('node:sqlite') + const db = new DatabaseSync(workerData.path) + db.exec('BEGIN IMMEDIATE') + parentPort.postMessage('locked') + Atomics.wait(workerData.signal, 0, 0) + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300) + db.exec('COMMIT') + db.close()`, + { eval: true, workerData: { path, signal } } + ) + await new Promise((resolve, reject) => { + worker.once('message', () => resolve()) + worker.once('error', reject) + }) + return { + flushing: () => { + Atomics.store(signal, 0, 1) + Atomics.notify(signal, 0) + }, + exited: new Promise((resolve) => worker.once('exit', () => resolve())) + } +} + +describe('saved chat status store', () => { + it('reads back what it wrote after a reopen', () => { + const dir = tempDir() + const store = open(dir) + store.record('s1', saved(10)) + expect(store.read('s1')).toEqual(saved(10)) + store.close() + expect(open(dir).read('s1')).toEqual(saved(10)) + }) + + it('writes many coalesced entries in one transaction, and flushes at close', () => { + const dir = tempDir() + const store = open(dir) + const exec = vi.spyOn(Database.prototype, 'exec') + for (let sequence = 1; sequence <= 20; sequence += 1) { + store.record('s1', saved(sequence)) + store.record('s2', saved(sequence, 'other')) + } + expect(exec.mock.calls.filter(([sql]) => sql === 'BEGIN IMMEDIATE')).toHaveLength(0) + store.close() + expect(exec.mock.calls.filter(([sql]) => sql === 'BEGIN IMMEDIATE')).toHaveLength(1) + const reopened = open(dir) + expect(reopened.read('s1')).toEqual(saved(20)) + expect(reopened.read('s2')).toEqual(saved(20, 'other')) + }) + + it('skips a flush at once while another process holds the lock, and the next flush writes', async () => { + const dir = tempDir() + const store = open(dir) + store.record('s1', saved(10)) + const lock = await holdWriteLock(join(dir, AGENT_SESSION_SAVED_STATUS_FILE)) + lock.flushing() + // A flush that waited on the lock would outlast the holder and succeed; a skip answers false. + expect(store.flush()).toBe(false) + await lock.exited + expect(store.flush()).toBe(true) + store.close() + expect(open(dir).read('s1')).toEqual(saved(10)) + }) + + it('deletes and recreates a file that is not a database, and later writes land', () => { + const dir = tempDir() + writeFileSync(join(dir, AGENT_SESSION_SAVED_STATUS_FILE), Buffer.alloc(8192, 7)) + const store = open(dir) + expect(store.enabled).toBe(true) + store.record('s1', saved(3)) + store.close() + expect(open(dir).read('s1')).toEqual(saved(3)) + }) + + it('leaves a newer build’s file untouched and reads or writes nothing from it', () => { + const dir = tempDir() + const path = join(dir, AGENT_SESSION_SAVED_STATUS_FILE) + const newer = new Database(path) + newer.exec('CREATE TABLE later (x INTEGER)') + newer.pragma('user_version = 99') + newer.close() + const before = readFileSync(path) + const store = open(dir) + expect(store.enabled).toBe(false) + store.record('s1', saved(3)) + expect(store.read('s1')).toBeNull() + store.close() + expect(readFileSync(path).equals(before)).toBe(true) + }) + + it('prunes entries whose chat is gone', () => { + const dir = tempDir() + const store = open(dir) + store.record('keep', saved(1)) + store.record('gone', saved(1)) + store.flush() + store.prune((sessionId) => sessionId === 'keep') + store.close() + const reopened = open(dir) + expect(reopened.read('keep')).toEqual(saved(1)) + expect(reopened.read('gone')).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-saved-status-store.ts b/src/main/runtime/agent-session-saved-status-store.ts new file mode 100644 index 000000000000..f4fa1586cc27 --- /dev/null +++ b/src/main/runtime/agent-session-saved-status-store.ts @@ -0,0 +1,272 @@ +// Each chat's last settled status, kept across a restart so the host can list a chat without +// replaying its journal. A cache: every entry is checked against its journal's position before it +// is used, so a lost or stale write costs one journal open and nothing else. +// +// Writes are coalesced and never on the hot path: the status memo recomputes on every journal +// move, so an entry only joins an in-memory dirty map, and a short timer writes the map in one +// transaction. A lock held by another process is a skip the next flush retries, never a wait on +// the main thread. + +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import type { StructuredAgentSessionSavedStatus } from '../../shared/structured-agent-session-saved-status' +import { parseStructuredAgentSessionSavedProjection } from '../../shared/structured-agent-session-saved-status' +import { hardenSqliteDatabaseFiles } from '../sqlite/harden-database-files' +import { isUnusableSqliteDatabaseError } from '../sqlite/sqlite-read-failure' +import Database from '../sqlite/sync-database' + +export const AGENT_SESSION_SAVED_STATUS_FILE = 'agent-session-saved-status.db' +const SCHEMA_VERSION = 1 +const FLUSH_DELAY_MS = 250 + +const CREATE_TABLE = `CREATE TABLE saved_status ( + session_id TEXT PRIMARY KEY, + v INTEGER NOT NULL, + epoch TEXT NOT NULL, + sequence INTEGER NOT NULL, + projection_json TEXT NOT NULL, + last_activity_at INTEGER NOT NULL +)` +const SELECT_ONE = + 'SELECT v, epoch, sequence, projection_json, last_activity_at FROM saved_status WHERE session_id = ?' +const UPSERT = `INSERT INTO saved_status + (session_id, v, epoch, sequence, projection_json, last_activity_at) VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT(session_id) DO UPDATE SET v = excluded.v, epoch = excluded.epoch, + sequence = excluded.sequence, projection_json = excluded.projection_json, + last_activity_at = excluded.last_activity_at` +const SELECT_IDS = 'SELECT session_id FROM saved_status' +const DELETE_ONE = 'DELETE FROM saved_status WHERE session_id = ?' + +function warn(message: string, error: unknown): void { + console.warn(`[agent-session-saved-status] ${message}`, error) +} + +/** Null disables the store for this run: a newer build's file is left untouched. */ +function openDatabase(path: string): Database.Database | null { + const db = new Database(path) + try { + // Read before any pragma that writes: a newer build's file must stay byte-identical. + const stored = Number(db.pragma('user_version', { simple: true }) ?? 0) + if (stored > SCHEMA_VERSION) { + db.close() + return null + } + db.pragma('journal_mode = WAL') + // A lost write costs a miss, so a commit needs no fsync, and a busy lock is a skip. + db.pragma('synchronous = NORMAL') + db.pragma('busy_timeout = 0') + if (stored < SCHEMA_VERSION) { + db.exec('BEGIN IMMEDIATE') + db.exec(`DROP TABLE IF EXISTS saved_status; ${CREATE_TABLE}`) + db.pragma(`user_version = ${SCHEMA_VERSION}`) + db.exec('COMMIT') + } + hardenSqliteDatabaseFiles(path) + return db + } catch (error) { + try { + db.close() + } catch { + // The open already failed; its error is the one worth reporting. + } + throw error + } +} + +function removeDatabaseFiles(path: string): void { + for (const file of [path, `${path}-wal`, `${path}-shm`]) { + rmSync(file, { force: true }) + } +} + +export class AgentSessionSavedStatusStore { + private readonly dirty = new Map() + private timer: ReturnType | null = null + + private constructor( + private readonly path: string, + private db: Database.Database | null + ) {} + + /** Never throws: a store that cannot open runs disabled, and every chat misses. */ + static open(stateDirectory: string): AgentSessionSavedStatusStore { + const path = join(stateDirectory, AGENT_SESSION_SAVED_STATUS_FILE) + return new AgentSessionSavedStatusStore(path, AgentSessionSavedStatusStore.openOrRecreate(path)) + } + + /** A corrupt file is deleted and recreated, never left to disable the store for good. */ + private static openOrRecreate(path: string): Database.Database | null { + try { + return openDatabase(path) + } catch (error) { + if (!isUnusableSqliteDatabaseError(error)) { + warn('open failed; saved statuses are off for this run', error) + return null + } + try { + removeDatabaseFiles(path) + return openDatabase(path) + } catch (recreateError) { + warn('recreating an unusable file failed', recreateError) + return null + } + } + } + + get enabled(): boolean { + return this.db !== null + } + + read(sessionId: string): StructuredAgentSessionSavedStatus | null { + const pending = this.dirty.get(sessionId) + if (pending) { + return pending + } + try { + const row = this.db?.prepare(SELECT_ONE).get(sessionId) + const projection = + typeof row?.projection_json === 'string' + ? parseStructuredAgentSessionSavedProjection(row.projection_json) + : null + if ( + !row || + !projection || + typeof row.v !== 'number' || + typeof row.epoch !== 'string' || + typeof row.sequence !== 'number' || + typeof row.last_activity_at !== 'number' + ) { + return null + } + return { + v: row.v, + cursor: { epoch: row.epoch, sequence: row.sequence }, + projection, + lastActivityAt: row.last_activity_at + } + } catch (error) { + warn('read failed', error) + return null + } + } + + /** Coalesced: the latest entry per chat is written by the next flush. */ + record(sessionId: string, saved: StructuredAgentSessionSavedStatus): void { + if (!this.db) { + return + } + this.dirty.set(sessionId, saved) + this.timer ??= setTimeout(() => { + this.timer = null + this.flush() + }, FLUSH_DELAY_MS) + this.timer.unref?.() + } + + /** Writes every dirty entry in one transaction. False when the write was skipped; the entries + * stay dirty for the next flush. */ + flush(): boolean { + const db = this.db + if (!db || this.dirty.size === 0) { + return true + } + const entries = [...this.dirty] + try { + db.exec('BEGIN IMMEDIATE') + try { + const upsert = db.prepare(UPSERT) + for (const [sessionId, saved] of entries) { + upsert.run( + sessionId, + saved.v, + saved.cursor.epoch, + saved.cursor.sequence, + JSON.stringify(saved.projection), + saved.lastActivityAt + ) + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + } catch (error) { + warn('flush skipped; the next flush retries', error) + if (isUnusableSqliteDatabaseError(error)) { + this.recreate() + } + return false + } + for (const [sessionId, saved] of entries) { + if (this.dirty.get(sessionId) === saved) { + this.dirty.delete(sessionId) + } + } + return true + } + + /** Drops entries for chats that no longer exist, so the table stays bounded by the records. */ + prune(keep: (sessionId: string) => boolean): void { + for (const sessionId of this.dirty.keys()) { + if (!keep(sessionId)) { + this.dirty.delete(sessionId) + } + } + const db = this.db + if (!db) { + return + } + try { + const stale = db + .prepare(SELECT_IDS) + .all() + .map((row) => row.session_id) + .filter((sessionId): sessionId is string => typeof sessionId === 'string') + .filter((sessionId) => !keep(sessionId)) + if (stale.length === 0) { + return + } + db.exec('BEGIN IMMEDIATE') + const remove = db.prepare(DELETE_ONE) + for (const sessionId of stale) { + remove.run(sessionId) + } + db.exec('COMMIT') + } catch (error) { + if (db.isTransaction) { + db.exec('ROLLBACK') + } + warn('prune skipped', error) + } + } + + /** Flushes, then closes. After host teardown, so teardown's own settlements are written. */ + close(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + this.flush() + try { + this.db?.close() + } catch (error) { + warn('close failed', error) + } + this.db = null + } + + private recreate(): void { + try { + this.db?.close() + } catch { + // The file is being replaced; its handle has nothing left to lose. + } + try { + removeDatabaseFiles(this.path) + this.db = openDatabase(this.path) + } catch (error) { + warn('recreating an unusable file failed', error) + this.db = null + } + } +} diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 2ef5b77069d6..032884769095 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -6,7 +6,6 @@ import type { } from '../../shared/runtime-types' import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records' import { - adjudicateAbsentMobileSessionTabClose, resolveMobileSessionLifecycleCloseContext, type MobileSessionLifecycleCloseHost } from './mobile-session-lifecycle-close-adjudication' @@ -86,10 +85,11 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU observedPtyIds }) if (!snapshot || !tab) { - return adjudicateAbsentMobileSessionTabClose({ + return this.adjudicateAbsentSessionTabClose({ host: this.getMobileSessionLifecycleCloseHost(), context: lifecycleClose, worktreeId, + tabId, snapshot, reason: options.reason, addressedByPtyCloseAuthority: options.expectedPtyCloseAuthority !== undefined diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 568083bf177f..4685ba1dfd00 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -1,5 +1,10 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithCloseMobileSessionTab } from './orca-runtime-close-mobile-session-tab' +import { adjudicateAbsentMobileSessionTabClose } from './mobile-session-lifecycle-close-adjudication' +import { + committedMobileSessionTabClose, + type MobileSessionTabCloseOutcome +} from './mobile-session-tab-close-outcome' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, @@ -17,7 +22,7 @@ import { retireStructuredAgentSessionTabFrom } from './structured-agent-session- export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { protected async closeStructuredAgentSessionTab(tab: RuntimeMobileSessionAgentTab): Promise { - const host = getStructuredAgentSessionHost() + const host = await this.structuredAgentSessionHostForTabClose() if (host) { if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(tab.sessionId, false) @@ -30,6 +35,51 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi } } + /** A close whose tab the runtime snapshot lacks. A restored chat the renderer shows from its saved + * session before the host has published it is still real: its durable record says so, and + * refusing it would leave the next listing to revive the tab the user closed. */ + protected async adjudicateAbsentSessionTabClose( + args: Parameters[0] & { tabId: string } + ): Promise { + const userClose = args.reason === undefined || args.reason === 'user' + if ( + userClose && + !args.addressedByPtyCloseAuthority && + args.tabId.startsWith('agent-session:') + ) { + const sessionId = args.tabId.slice('agent-session:'.length) + const host = await this.structuredAgentSessionHostForTabClose() + const record = host?.deps?.store?.getRecord?.(sessionId) + // Only a chat the index still lists, in the workspace the close names; anything else is unknown. + if ( + record?.location?.workspaceId === args.worktreeId && + host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) === true + ) { + await this.closeStructuredAgentSessionTab({ + type: 'agent-session', + id: args.tabId, + sessionId + }) + return committedMobileSessionTabClose(this.clientSessionTabSelections, args.worktreeId, [ + args.tabId + ]) + } + } + return adjudicateAbsentMobileSessionTabClose(args) + } + + /** A close before startup builds the host still owes the durable index its removal, or the tab + * returns at every launch. Bookkeeping only: a failed install must not keep the tab open. */ + private async structuredAgentSessionHostForTabClose() { + if (getStructuredAgentSessionHost() || !this.hasPersistedStructuredAgentSessionStore()) { + return getStructuredAgentSessionHost() + } + await this.ensureStructuredAgentSessionHost().catch((error) => { + console.warn('[structured-agent-session] host install failed before a tab close', error) + }) + return getStructuredAgentSessionHost() + } + /** * Prunes a structured session's chat tab from whichever worktree snapshot still carries it. * diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index f2143b9772c0..f20b3c732d7c 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -19,6 +19,7 @@ import { import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { structuredAgentSessionStartupPriority } from './saved-structured-agent-session-restoration' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -256,8 +257,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR } // Durable agent records must exist before daemon inventory can be reconciled against them. await this.ensureStructuredAgentSessionHost() + const host = getStructuredAgentSessionHost() + // Not awaited: the pass lists status first and opens journals behind every reader. + void host + ?.restoreStartupSessions?.( + structuredAgentSessionStartupPriority( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ) + ) + .catch((error) => console.error('[structured-agent-session] startup pass failed', error)) await this.refreshMobileSessionPtyRecords() - await getStructuredAgentSessionHost()?.reconcileRestartLeases() + await host?.reconcileRestartLeases() } protected hasPersistedStructuredAgentSessionStore(): boolean { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index d53b465d97d5..110b9561b285 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -1,5 +1,4 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-runtime-get-structured-agent-session-create-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' @@ -7,11 +6,14 @@ import type { ConversationReplacement } from '../native-chat/agent-session-wire/ import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { - RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsSnapshot, RuntimeRepoSearchRefs } from '../../shared/runtime-types' -import { getHeadlessMobileSessionGroupId } from './mobile-session-layout-projection' +import { + appendStructuredAgentSessionTabs, + structuredAgentSessionSnapshotTabId, + type StructuredAgentSessionTabToAppend +} from './structured-agent-session-tab-append' import { DEFAULT_REPO_SEARCH_REFS_LIMIT } from './orca-runtime-postlude' import type { Repo } from '../../shared/repo-types' import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' @@ -47,19 +49,17 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } } + // Tab existence needs only the opened store: reconcile, recovery and every journal open run + // behind the answer, in the startup pass prepare kicks. protected async restoreStructuredAgentSessionTabsOnce(): Promise { - await this.prepareStructuredAgentSessionStartupRestoration() + if (this.hasPersistedStructuredAgentSessionStore()) { + await this.ensureStructuredAgentSessionHost() + void this.prepareStructuredAgentSessionStartupRestoration().catch((error) => { + console.error('[structured-agent-session] startup restoration failed', error) + }) + } 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 - ) - await host?.restoreReadableSessions( - persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - ) + await this.adoptLegacyStructuredAgentSessionTabs(host) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -70,21 +70,59 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu for (const replacement of host?.conversationReplacements?.() ?? []) { await this.replaceStructuredAgentSessionTab(replacement) } - for (const session of host?.listSessionTabs() ?? []) { - if (session.agent !== 'codex' && session.agent !== 'claude') { + this.publishListedStructuredAgentSessionTabs(host) + } + + /** A store that predates the tab index lists its chats only in the saved workspace session. Each + * is written into the index, as the per-tab publish always did, so the first other write to the + * index cannot leave it holding one chat. */ + private async adoptLegacyStructuredAgentSessionTabs(host): Promise { + if (typeof host?.getPersistedVisibleSessionTabIndex !== 'function') { + return + } + if (host.getPersistedVisibleSessionTabIndex().present) { + return + } + const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + for (const sessionId of collectSavedStructuredAgentSessionIds(saved)) { + await host.setSessionTabVisibility(sessionId, true).catch(() => undefined) + } + } + + /** Every listed chat in one store write per workspace, from the index and the records alone. + * Synchronous from the index read to the last store, so a close lands wholly before or after. */ + private publishListedStructuredAgentSessionTabs(host): void { + if ( + typeof host?.getPersistedVisibleSessionTabIndex !== 'function' || + typeof host.listPersistedSessionTabs !== 'function' + ) { + return + } + const byWorkspace = new Map() + for (const tab of host.listPersistedSessionTabs( + host.getPersistedVisibleSessionTabIndex().sessionIds + )) { + if (tab.agent !== 'codex' && tab.agent !== 'claude') { continue } - let sessionId = session.sessionId + let sessionId = tab.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) } - await this.publishStructuredAgentSessionTab({ - ...session, - agent: session.agent, - sessionId, - activate: false, - notify: false - }) + const tabs = byWorkspace.get(tab.workspaceId) ?? [] + tabs.push({ sessionId, agent: tab.agent }) + byWorkspace.set(tab.workspaceId, tabs) + } + for (const [workspaceId, tabs] of byWorkspace) { + const next = appendStructuredAgentSessionTabs( + this.mobileSessionTabsByWorktree.get(workspaceId), + workspaceId, + tabs, + { activate: false } + ) + if (next) { + this.storeMobileSessionSnapshot(workspaceId, next) + } } } @@ -101,7 +139,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu await host.setSessionTabVisibility(input.sessionId, true) } const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) - const id = `agent-session:${input.sessionId}` + const id = structuredAgentSessionSnapshotTabId(input.sessionId) if (existing?.tabs.some((tab) => tab.id === id)) { // A background re-publish is a no-op — no store write, no emit — so it cannot re-surface a // client whose mirror lost the tab; healing one needs `activate` or an explicit republish. @@ -131,49 +169,20 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } return } - const tab: RuntimeMobileSessionAgentTab = { - type: 'agent-session', - id, - title: defaultAgentChatLabel(input.agent), - sessionId: input.sessionId, - ...(input.replacesSessionId ? { replacesSessionId: input.replacesSessionId } : {}), - agent: input.agent, - isActive: input.activate - } - const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({ - ...candidate, - isActive: input.activate ? false : candidate.isActive - })) - tabs.push(tab) - const priorGroups = existing?.tabGroups ?? [ - { - id: getHeadlessMobileSessionGroupId(input.workspaceId), - activeTabId: existing?.activeTabId ?? null, - tabOrder: [] - } - ] - const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId) - ? existing!.activeGroupId! - : priorGroups[0]!.id - const tabGroups = priorGroups.map((group) => - group.id === groupId - ? { - ...group, - activeTabId: input.activate ? id : group.activeTabId, - tabOrder: [...group.tabOrder, id] - } - : group + const snapshot = appendStructuredAgentSessionTabs( + existing, + input.workspaceId, + [ + { + sessionId: input.sessionId, + agent: input.agent, + ...(input.replacesSessionId ? { replacesSessionId: input.replacesSessionId } : {}) + } + ], + { activate: input.activate } ) - const snapshot: RuntimeMobileSessionTabsSnapshot = { - worktree: input.workspaceId, - publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, - snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId), - activeTabId: input.activate ? id : (existing?.activeTabId ?? null), - activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null), - tabGroups, - ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), - tabs + if (!snapshot) { + return } const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot) if (input.notify !== false) { diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index d6ec1e22782c..6ec4653ddba3 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -4,6 +4,46 @@ import { OrcaRuntimeService } from './orca-runtime' afterEach(() => setStructuredAgentSessionHost(null)) +type RestoreInternals = { + store: { getWorkspaceSession: () => unknown } + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession( + worktreeId?: string, + options?: { allowAttachedWindow?: boolean; onlyRuntimeOwnedTerminals?: boolean } + ): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + storeMobileSessionSnapshot(worktreeId: string, snapshot: unknown): unknown +} + +function savedSessionWithChat(sessionId: string) { + return { + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: `agent-session:${sessionId}`, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: { 'workspace-1': `agent-session:${sessionId}` }, + unifiedTabs: { + 'workspace-1': [ + { + id: `agent-session:${sessionId}`, + entityId: sessionId, + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } +} + describe('structured session cold restoration', () => { it('skips every heavy recovery step when no durable session store exists', async () => { const runtime = new OrcaRuntimeService() @@ -18,6 +58,7 @@ describe('structured session cold restoration', () => { internal.hasPersistedStructuredAgentSessionStore = () => false internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. setStructuredAgentSessionHost({ reconcileRestartLeases } as never) await runtime.prepareStructuredAgentSessionStartupRestoration() @@ -27,179 +68,133 @@ describe('structured session cold restoration', () => { expect(reconcileRestartLeases).not.toHaveBeenCalled() }) - it('keeps historical journal parsing outside the terminal-safety fence', async () => { + it('kicks the startup pass without waiting on it, then reconciles once', async () => { const runtime = new OrcaRuntimeService() 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 internal = runtime as unknown as { - hasPersistedStructuredAgentSessionStore(): boolean - refreshMobileSessionPtyRecords(): Promise | null> - ensureStructuredAgentSessionHost(): Promise - } + const restoreStartupSessions = vi.fn(() => new Promise(() => undefined)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as RestoreInternals internal.hasPersistedStructuredAgentSessionStore = () => true internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost - setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. + setStructuredAgentSessionHost({ reconcileRestartLeases, restoreStartupSessions } as never) + await runtime.prepareStructuredAgentSessionStartupRestoration() await runtime.prepareStructuredAgentSessionStartupRestoration() expect(ensureHost).toHaveBeenCalledOnce() expect(refresh).toHaveBeenCalledOnce() expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).not.toHaveBeenCalled() + expect(restoreStartupSessions).toHaveBeenCalledOnce() + expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan( + restoreStartupSessions.mock.invocationCallOrder[0] ?? Infinity + ) }) - it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { + it('answers the tab restore from the index while reconcile and the startup pass hang', async () => { const runtime = new OrcaRuntimeService() 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 internal = runtime as unknown as { - hasPersistedStructuredAgentSessionStore(): boolean - getKnownWorkspaceSessionWorktreeIds(): Set - hydrateHeadlessMobileSessionTabsFromWorkspaceSession( - worktreeId?: string, - options?: { allowAttachedWindow?: boolean; onlyRuntimeOwnedTerminals?: boolean } - ): Set - refreshMobileSessionPtyRecords(): Promise | null> - ensureStructuredAgentSessionHost(): Promise - } + const reconcileRestartLeases = vi.fn(() => new Promise(() => undefined)) + const restoreStartupSessions = vi.fn(() => new Promise(() => undefined)) + const setSessionTabVisibility = vi.fn(async () => undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as RestoreInternals internal.hasPersistedStructuredAgentSessionStore = () => true internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrate - internal.refreshMobileSessionPtyRecords = refresh - internal.ensureStructuredAgentSessionHost = ensureHost + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + const store = vi.spyOn(internal, 'storeMobileSessionSnapshot') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. setStructuredAgentSessionHost({ reconcileRestartLeases, - restoreReadableSessions, - listSessionTabs: () => [] + restoreStartupSessions, + setSessionTabVisibility, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['a', 'b', 'c'] }), + listPersistedSessionTabs: (ids: readonly string[]) => + ids.map((sessionId) => ({ + sessionId, + workspaceId: sessionId === 'c' ? 'workspace-2' : 'workspace-1', + agent: 'codex' + })) } as never) const first = runtime.restoreStructuredAgentSessionTabs() const second = runtime.restoreStructuredAgentSessionTabs() expect(second).toBe(first) - await Promise.all([first, second]) + await first expect(hydrate).toHaveBeenCalledWith('workspace-1', { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) - expect(hydrate).toHaveBeenCalledWith() - expect(refresh).toHaveBeenCalledOnce() - expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).toHaveBeenCalledOnce() - expect(ensureHost).toHaveBeenCalledOnce() - expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan( - refresh.mock.invocationCallOrder[0] ?? Infinity - ) - expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( - reconcileRestartLeases.mock.invocationCallOrder[0] ?? Infinity - ) - expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan( - restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity - ) - expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan( - hydrate.mock.invocationCallOrder[0] ?? Infinity - ) + expect(restoreStartupSessions).toHaveBeenCalledOnce() + // One store write per workspace, and no visibility write: the ids came from the index. + expect(store.mock.calls.map(([workspaceId]) => workspaceId)).toEqual([ + 'workspace-1', + 'workspace-2' + ]) + expect(setSessionTabVisibility).not.toHaveBeenCalled() + const listed = await runtime.listMobileSessionTabs('id:workspace-1') + expect(listed.tabs.map((tab) => tab.id)).toEqual(['agent-session:a', 'agent-session:b']) }) - it('prefers the durable visible-session index after a legacy profile drops agent tabs', async () => { + it('lists exactly what the durable index holds, including nothing', async () => { const runtime = new OrcaRuntimeService() - const restoreReadableSessions = vi.fn(async () => undefined) - const internal = runtime as unknown as { - store: { getWorkspaceSession: () => unknown } - hasPersistedStructuredAgentSessionStore(): boolean - getKnownWorkspaceSessionWorktreeIds(): Set - hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set - refreshMobileSessionPtyRecords(): Promise | null> - ensureStructuredAgentSessionHost(): Promise - } - internal.store = { - getWorkspaceSession: () => ({ - activeRepoId: null, - activeWorktreeId: 'workspace-1', - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - unifiedTabs: { 'workspace-1': [] } - }) - } + const listPersistedSessionTabs = vi.fn(() => []) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as RestoreInternals + internal.store = { getWorkspaceSession: () => savedSessionWithChat('closed-session') } internal.hasPersistedStructuredAgentSessionStore = () => true internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, - getPersistedVisibleSessionTabIndex: () => ({ - present: true, - sessionIds: ['session-survives-rollback'] - }), - restoreReadableSessions, - listSessionTabs: () => [] + restoreStartupSessions: async () => undefined, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), + listPersistedSessionTabs } as never) await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) + expect(listPersistedSessionTabs).toHaveBeenCalledExactlyOnceWith([]) }) - it('treats an empty durable visible-session index as authoritative', async () => { + it('writes a store without an index every chat its saved session shows', async () => { const runtime = new OrcaRuntimeService() - const restoreReadableSessions = vi.fn(async () => undefined) - const internal = runtime as unknown as { - store: { getWorkspaceSession: () => unknown } - hasPersistedStructuredAgentSessionStore(): boolean - getKnownWorkspaceSessionWorktreeIds(): Set - hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set - 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 index: { present: boolean; sessionIds: string[] } = { present: false, sessionIds: [] } + const setSessionTabVisibility = vi.fn(async (sessionId: string) => { + index.present = true + index.sessionIds.push(sessionId) + }) + const listPersistedSessionTabs = vi.fn(() => []) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as RestoreInternals + internal.store = { getWorkspaceSession: () => savedSessionWithChat('legacy-session') } internal.hasPersistedStructuredAgentSessionStore = () => true internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), - restoreReadableSessions, - listSessionTabs: () => [] + restoreStartupSessions: async () => undefined, + setSessionTabVisibility, + getPersistedVisibleSessionTabIndex: () => index, + listPersistedSessionTabs } as never) await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith([]) + expect(setSessionTabVisibility).toHaveBeenCalledExactlyOnceWith('legacy-session', true) + expect(listPersistedSessionTabs).toHaveBeenCalledExactlyOnceWith(['legacy-session']) }) it('normalizes a restored tab id and removes it when closed', async () => { @@ -223,12 +218,14 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + restoreStartupSessions: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, - listSessionTabs: () => [ + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['restored'] }), + listPersistedSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', workspaceId: 'workspace-1', @@ -327,23 +324,19 @@ describe('structured session cold restoration', () => { it('publishes restored Claude tabs with the Claude title', async () => { const runtime = new OrcaRuntimeService() - const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') - const internal = runtime as unknown as { - hasPersistedStructuredAgentSessionStore(): boolean - getKnownWorkspaceSessionWorktreeIds(): Set - hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set - refreshMobileSessionPtyRecords(): Promise | null> - ensureStructuredAgentSessionHost(): Promise - } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as RestoreInternals internal.hasPersistedStructuredAgentSessionStore = () => true internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, - listSessionTabs: () => [ + restoreStartupSessions: async () => undefined, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['restored'] }), + listPersistedSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', workspaceId: 'workspace-1', @@ -354,25 +347,17 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(publish).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - sessionId: 'restored-claude', - agent: 'claude', - activate: false, - notify: false - }) - const restored = await runtime.listMobileSessionTabs('id:workspace-1') - expect(restored.tabs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'agent-session', - id: 'agent-session:restored-claude', - title: 'Claude Chat', - agent: 'claude' - }) - ]) - ) + expect(restored.tabs).toEqual([ + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:restored-claude', + sessionId: 'restored-claude', + title: 'Claude Chat', + agent: 'claude', + isActive: false + }) + ]) }) it('commits the host close when the renderer already removed the structured tab', async () => { diff --git a/src/main/runtime/saved-structured-agent-session-restoration.ts b/src/main/runtime/saved-structured-agent-session-restoration.ts index 71d617de81a0..a784eea58e40 100644 --- a/src/main/runtime/saved-structured-agent-session-restoration.ts +++ b/src/main/runtime/saved-structured-agent-session-restoration.ts @@ -1,6 +1,11 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { Tab } from '../../shared/tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + getActiveSidebarWorkspaceId, + normalizeWorkspaceSessionKeyToWorkspaceId +} from '../../shared/workspace-scope' +import type { StructuredAgentSessionStartupPriority } from '../native-chat/agent-session-wire/structured-agent-session-restart-restore' function savedSessionId(tab: Tab): string | null { if (tab.executionHostId && tab.executionHostId !== LOCAL_EXECUTION_HOST_ID) { @@ -41,3 +46,24 @@ export function collectSavedStructuredAgentSessionIds( } return selected } + +/** Which workspaces the startup pass reaches first: the one the window last showed, then any with + * a saved tab. Null without a saved desktop session (`orca serve`, SSH), keeping listing order. */ +export function structuredAgentSessionStartupPriority( + session: WorkspaceSessionState | null +): StructuredAgentSessionStartupPriority | undefined { + if (!session) { + return undefined + } + const withTabs = [ + ...Object.keys(session.unifiedTabs ?? {}), + ...Object.keys(session.tabsByWorktree ?? {}) + ].map(normalizeWorkspaceSessionKeyToWorkspaceId) + return { + activeWorkspaceId: getActiveSidebarWorkspaceId( + session.activeWorkspaceKey ?? null, + session.activeWorktreeId + ), + visibleWorkspaceIds: new Set(withTabs) + } +} 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 3da0c6f203d2..6a4191fe294f 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -224,7 +224,7 @@ describe('structured session runtime provider-exit wiring', () => { openCodexConnection: openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) - await restarted.restoreReadableSessions() + await restarted.restoreStartupSessions() 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-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 90f4ff92c4bc..f170ff65bb7f 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -10,6 +10,8 @@ import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wi export type InstalledRuntime = { host: StructuredAgentSessionHost adapter: { closeAll(): Promise } + /** Flushed and closed last, so the settled statuses teardown itself wrote are saved. */ + savedStatus?: { close(): void } /** Resolves after every observed adapter exit has published, and every * recovery callback it raised has settled. */ waitForRecovery: () => Promise @@ -65,6 +67,7 @@ export async function tearDownRuntime( } catch (error) { failures.push(error) } + installed.savedStatus?.close() if (failures.length === 1) { throw failures[0] } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index cac10badafc8..dd71b8c31ae4 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -19,6 +19,7 @@ import { type InstalledRuntime } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' +import { AgentSessionSavedStatusStore } from './agent-session-saved-status-store' import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' import { @@ -294,10 +295,12 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { await Promise.all([codex.closeAll(), claude.closeAll()]) }) + const savedStatus = AgentSessionSavedStatusStore.open(deps.stateDirectory) host = new StructuredAgentSessionHost({ store, adapter, recoveryCapsule: new AgentSessionRecoveryCapsule(deps.stateDirectory), + savedStatus, journalRoot: deps.stateDirectory, claimKeyId: deps.claimKeyId, probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), @@ -319,6 +322,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise tab.id)) + const added: RuntimeMobileSessionAgentTab[] = [] + for (const input of tabs) { + const id = structuredAgentSessionSnapshotTabId(input.sessionId) + if (held.has(id)) { + continue + } + held.add(id) + added.push({ + type: 'agent-session', + id, + title: defaultAgentChatLabel(input.agent), + sessionId: input.sessionId, + ...(input.replacesSessionId ? { replacesSessionId: input.replacesSessionId } : {}), + agent: input.agent, + isActive: false + }) + } + const last = added.at(-1) + if (!last) { + return null + } + const activeId = options.activate ? last.id : null + if (activeId) { + last.isActive = true + } + const priorGroups = existing?.tabGroups ?? [ + { + id: getHeadlessMobileSessionGroupId(workspaceId), + activeTabId: existing?.activeTabId ?? null, + tabOrder: [] + } + ] + const groupId = + priorGroups.find((group) => group.id === existing?.activeGroupId)?.id ?? priorGroups[0]!.id + return { + worktree: workspaceId, + publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: activeId ? groupId : (existing?.activeGroupId ?? groupId), + activeTabId: activeId ?? existing?.activeTabId ?? null, + activeTabType: activeId ? 'agent-session' : (existing?.activeTabType ?? null), + tabGroups: priorGroups.map((group) => + group.id === groupId + ? { + ...group, + activeTabId: activeId ?? group.activeTabId, + tabOrder: [...group.tabOrder, ...added.map((tab) => tab.id)] + } + : group + ), + ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), + tabs: [ + ...(existing?.tabs ?? []).map((tab) => (activeId ? { ...tab, isActive: false } : tab)), + ...added + ] + } +} diff --git a/src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts b/src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts new file mode 100644 index 000000000000..f278ecb90e9d --- /dev/null +++ b/src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts @@ -0,0 +1,73 @@ +// A restored chat the renderer shows from its saved session before the host has listed it: the +// user can close it, and that close has to reach the durable index or the next listing revives it. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { OrcaRuntimeService } from './orca-runtime' + +afterEach(() => setStructuredAgentSessionHost(null)) + +type RuntimeInternals = { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise +} + +/** A host installed only when something asks for it, with one chat record in `workspace-1`. */ +function runtimeInstallingHost(listed: readonly string[]) { + const setSessionTabVisibility = vi.fn(async () => undefined) + const close = vi.fn(async () => undefined) + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as RuntimeInternals + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = async () => { + const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'kept' })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. + setStructuredAgentSessionHost({ + deps: { + store: { + getRecord: (sessionId: string) => + sessionId === 'kept' + ? { ...record, location: { ...record.location, workspaceId: 'workspace-1' } } + : null + } + }, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...listed] }), + setSessionTabVisibility, + close + } as never) + } + return { runtime, setSessionTabVisibility, close } +} + +describe('closing a restored chat before its tab is listed', () => { + it('installs the host and retires the chat from the durable index', async () => { + const { runtime, setSessionTabVisibility, close } = runtimeInstallingHost(['kept']) + + await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:kept', { reason: 'user' }) + + expect(setSessionTabVisibility).toHaveBeenCalledWith('kept', false) + expect(close).toHaveBeenCalledWith('kept') + }) + + it.each([ + ['names another workspace', 'workspace-2', ['kept']], + ['addresses a chat whose tab is already closed', 'workspace-1', []] + ])( + 'refuses an unlisted chat close that %s, as for any unknown tab', + async (_case, worktreeId, listed) => { + const { runtime, setSessionTabVisibility, close } = runtimeInstallingHost(listed) + + await expect( + runtime.closeMobileSessionTab(`id:${worktreeId}`, 'agent-session:kept', { reason: 'user' }) + ).rejects.toThrow('tab_not_found') + + expect(setSessionTabVisibility).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/shared/structured-agent-session-saved-status.test.ts b/src/shared/structured-agent-session-saved-status.test.ts new file mode 100644 index 000000000000..5601ad189fe8 --- /dev/null +++ b/src/shared/structured-agent-session-saved-status.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalDispatchState, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import { projectStructuredAgentSessionStatusSummary } from './structured-agent-session-projection' +import { + isSavableStructuredAgentSessionProjection, + parseStructuredAgentSessionSavedProjection, + STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION +} from './structured-agent-session-saved-status' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence * 1_000, body } +} + +function submission( + clientMessageId: string, + fence: number, + dispatchState: AgentJournalDispatchState +): AgentJournalSubmission { + return { + clientMessageId, + fence, + payloadFingerprint: clientMessageId, + dispatchState, + providerItemId: null, + reason: null, + submittedAt: fence * 1_000, + resolvedAt: dispatchState === 'pending' ? null : fence * 1_000 + 1 + } +} + +const settledTurn: AgentJournalRenderItem[] = [ + item('user-1', 1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix it' }] }), + item('turn-1', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + startedAt: 2_000, + completedAt: 5_000 + }), + item('reply-1', 3, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Fixed.' }] + }) +] + +describe('saved chat status', () => { + // A saved copy is trusted across builds only while this output is unchanged. If this fails, + // bump STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, then update the snapshot (C14). + it('pins what a settled chat projects under the current projection version', () => { + expect({ + version: STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + projection: projectStructuredAgentSessionStatusSummary(settledTurn) + }).toMatchInlineSnapshot(` + { + "projection": { + "lastAssistantMessage": "Fixed.", + "latestPrompt": "Fix it", + "status": "idle", + "statusStartedAt": 5000, + }, + "version": 1, + } + `) + }) + + // What makes a saved copy fence-free: a settled projection can only stay settled as the fence + // rises, because a higher fence only stops older sends from counting (C9). + it('keeps every idle or no-turn projection unchanged as the fence rises', () => { + const states: AgentJournalDispatchState[] = ['pending', 'accepted', 'rejected', 'unknown'] + const histories: AgentJournalRenderItem[][] = [[], settledTurn] + let checked = 0 + for (const items of histories) { + for (const first of states) { + for (const second of states) { + for (const firstFence of [1, 2, 3]) { + const submissions = [ + submission('send-1', firstFence, first), + submission('send-2', 1, second) + ] + for (const fence of [1, 2, 3]) { + const settled = projectStructuredAgentSessionStatusSummary(items, submissions, fence) + if (!isSavableStructuredAgentSessionProjection(settled)) { + continue + } + checked += 1 + for (const later of [fence + 1, fence + 5]) { + expect( + projectStructuredAgentSessionStatusSummary(items, submissions, later) + ).toEqual(settled) + } + } + } + } + } + } + expect(checked).toBeGreaterThan(0) + }) + + it('round-trips a settled projection and refuses anything a saved copy never holds', () => { + const settled = projectStructuredAgentSessionStatusSummary(settledTurn) + expect(parseStructuredAgentSessionSavedProjection(JSON.stringify(settled))).toEqual(settled) + expect( + parseStructuredAgentSessionSavedProjection(JSON.stringify({ status: null, latestPrompt: '' })) + ).toEqual({ status: null, latestPrompt: '' }) + for (const unsaved of [ + { ...settled, status: 'working' }, + { ...settled, toolName: 'Read' }, + { ...settled, turnOutcome: 'not-an-outcome' }, + { status: 'idle' } + ]) { + expect(parseStructuredAgentSessionSavedProjection(JSON.stringify(unsaved))).toBeNull() + } + expect(parseStructuredAgentSessionSavedProjection('{not json')).toBeNull() + }) +}) diff --git a/src/shared/structured-agent-session-saved-status.ts b/src/shared/structured-agent-session-saved-status.ts new file mode 100644 index 000000000000..998ef1483feb --- /dev/null +++ b/src/shared/structured-agent-session-saved-status.ts @@ -0,0 +1,71 @@ +// A chat's last settled status, saved so a restarted host can list it without replaying its journal. +// +// Only an idle or no-turn projection is saved, keyed by the journal position it was computed at. +// A restart trusts it only when the journal still stands exactly there; anything else is a miss and +// the journal is opened, so a saved copy can go stale but can never be shown stale. + +import type { AgentJournalCursor } from './agent-session-journal-types' +import { isAgentJournalTurnOutcome } from './agent-turn-outcome' +import type { StructuredAgentSessionStatusProjection } from './structured-agent-session-projection' + +/** Bump whenever the projection or the reducer changes what an idle or no-turn chat projects. */ +export const STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION = 1 + +export type StructuredAgentSessionSavedStatus = { + v: number + /** Where the journal stood when `projection` was computed. */ + cursor: AgentJournalCursor + projection: StructuredAgentSessionStatusProjection + /** The journal's `lastActivityAt()` at that position, which dates the row. */ + lastActivityAt: number +} + +/** Whether a projection is one a saved copy may hold: settled, so no fence can change it. */ +export function isSavableStructuredAgentSessionProjection( + projection: StructuredAgentSessionStatusProjection +): boolean { + return projection.status === null || projection.status === 'idle' +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string' +} + +/** The saved projection, or null for anything this build did not write: a miss, never an error. */ +export function parseStructuredAgentSessionSavedProjection( + json: string +): StructuredAgentSessionStatusProjection | null { + let raw: unknown + try { + raw = JSON.parse(json) + } catch { + return null + } + if (typeof raw !== 'object' || raw === null) { + return null + } + const value: Record = { ...raw } + const { status, latestPrompt, lastAssistantMessage, turnOutcome, statusStartedAt } = value + if (status !== null && status !== 'idle') { + return null + } + if (typeof latestPrompt !== 'string' || !optionalString(lastAssistantMessage)) { + return null + } + if (value.toolName !== undefined || value.toolInput !== undefined) { + return null + } + if (turnOutcome !== undefined && !isAgentJournalTurnOutcome(turnOutcome)) { + return null + } + if (statusStartedAt !== undefined && typeof statusStartedAt !== 'number') { + return null + } + return { + status, + latestPrompt, + ...(typeof lastAssistantMessage === 'string' ? { lastAssistantMessage } : {}), + ...(isAgentJournalTurnOutcome(turnOutcome) ? { turnOutcome } : {}), + ...(typeof statusStartedAt === 'number' ? { 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 884b5befdbfc..d0c12fa31215 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 @@ -718,9 +718,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. + // What every boot runs before its startup pass; it opens no journal. The hold and the + // reads open it on first use, and the hold gives the session a child again. + await restarted.reconcileRestartLeases() await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' }) const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(resumedFence).toBeGreaterThan(created.fence) From 7e1e030c11106761917b33e2562dfdf515bdb4ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:09:10 -0700 Subject: [PATCH 076/101] fix(native-chat): keep chat restart bookkeeping off the workspace's first paint The renderer awaited host install, the PTY census and every restart lease's reconcile before it reconnected terminals, and then awaited the chat tab inventory before it let the session writer save. A workspace whose active tab was a chat stayed blank for all of it. The prepare handler now answers once the first-window barriers settle and starts chat restoration without waiting, and the renderer starts the chat tab projection without waiting. Until the inventory lands the saved chat tabs stay as hydrated, and every host answer that carries chat tabs waits for the host's listing, so a save in that window cannot lose a chat tab. A structured worker release now installs the host and waits for the restart reconcile itself. --- .../worker/structured-worker-release-host.ts | 34 ++++ .../worker/worker-release-completion.ts | 17 +- ...orker-release-structured-reconcile.test.ts | 58 ++++++ .../session-tabs-listing-order.test.ts | 113 +++++++++++ .../structured-worker-tab-retirement.test.ts | 1 + .../main-process-ipc-bootstrap.test.ts | 58 ++++++ .../startup/main-process-ipc-bootstrap.ts | 6 +- .../app-shell/use-app-startup-hydration.ts | 6 +- src/renderer/src/app-startup-routing.test.ts | 10 + ...ructured-session-tabs-paint-window.test.ts | 176 ++++++++++++++++++ 10 files changed, 461 insertions(+), 18 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-host.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts create mode 100644 src/main/startup/main-process-ipc-bootstrap.test.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-paint-window.test.ts diff --git a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-host.ts b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-host.ts new file mode 100644 index 000000000000..5d0f5fd0fad2 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-host.ts @@ -0,0 +1,34 @@ +import { getStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from '../../../../orca-runtime' + +/** + * The structured host a worker release reads, installed and with its restart leases settled. + * + * Observation and archive capture both read the structured host, and after a restart nothing has + * installed it yet — the startup recovery reconciler runs exactly this path. Installing it here is + * what lets the release see the session instead of reporting it unreadable. Startup no longer + * reconciles ahead of this pass either, and an unreconciled worker that ended with the previous app + * run still reads as live, so the release waits for that reconcile too. + * + * NOT yet handled, and deliberately follow-up: rebinding a restarted runtime to a structured + * worker's hold and redrive subscription. Until that exists, a worker that survives a restart + * keeps no hold, so its child is evictable and its parked mail waits for the next arrival rather + * than a settle edge. + */ +export async function prepareStructuredHostForWorkerRelease( + runtime: Pick, + dispatchId: string +): Promise { + await runtime.ensureStructuredAgentSessionHost().catch((error: unknown) => { + console.warn('[orchestration] structured host install failed before release', dispatchId, error) + }) + await getStructuredAgentSessionHost() + ?.reconcileRestartLeases() + .catch((error: unknown) => { + console.warn( + '[orchestration] structured lease reconcile failed before release', + dispatchId, + error + ) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts index b686cdfe6cd4..da42f1bb5bc0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts @@ -19,6 +19,7 @@ import { workerTerminalLeaseIsCurrent } from './worker-terminal-release-lease' import { resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' import { stopStructuredWorkerForRelease } from './structured-worker-release-stop' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' +import { prepareStructuredHostForWorkerRelease } from './structured-worker-release-host' export { archiveSummary, @@ -93,21 +94,7 @@ async function completeWorkerTerminalReleaseOnce( ): Promise { const { runtime, db, dispatchId, resource } = args if (isStructuredWorkerHandle(resource.terminal_handle)) { - // Observation and archive capture both read the structured host, and after a restart nothing - // has installed it yet — the startup recovery reconciler runs exactly this path. Installing it - // here is what lets the release see the session instead of reporting it unreadable. - // - // NOT yet handled, and deliberately follow-up: rebinding a restarted runtime to a structured - // worker's hold and redrive subscription. Until that exists, a worker that survives a restart - // keeps no hold, so its child is evictable and its parked mail waits for the next arrival - // rather than a settle edge. - await runtime.ensureStructuredAgentSessionHost().catch((error: unknown) => { - console.warn( - '[orchestration] structured host install failed before release', - dispatchId, - error - ) - }) + await prepareStructuredHostForWorkerRelease(runtime, dispatchId) } const worker = db.getWorkerDispatch(dispatchId) if (!worker || worker.agent_terminal_handle !== resource.terminal_handle) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts new file mode 100644 index 000000000000..99a422597c87 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { mintStructuredWorkerHandle } from '../../../../structured-worker-identity' + +const { order } = vi.hoisted(() => { + const order: string[] = [] + return { order } +}) + +vi.mock('./worker-observation', () => ({ + inspectWorkerTerminal: vi.fn(async () => { + order.push('observe') + return { status: 'identity_changed' } + }) +})) + +const { completeWorkerTerminalRelease } = await import('./worker-release-completion') + +afterEach(() => { + order.length = 0 + setStructuredAgentSessionHost(null) +}) + +describe('structured worker release after a restart', () => { + it('reads the worker only once the restart reconcile has settled its lease', async () => { + const handle = mintStructuredWorkerHandle() + const runtime = { + ensureStructuredAgentSessionHost: vi.fn(async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release reads only the reconcile stubbed here. + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => { + await Promise.resolve() + order.push('reconcile') + } + } as never) + }) + } + const db = { + getWorkerDispatch: () => ({ agent_terminal_handle: handle }), + revertWorkerTerminalReleaseToRetained: () => ({ archive_source: null, archive_status: null }), + recordWorkerTerminalRecoveryAttempt: vi.fn() + } + + const receipt = await completeWorkerTerminalRelease({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this path reads only the runtime member stubbed above. + runtime: runtime as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retained path reads only the dispatch rows stubbed above. + db: db as never, + dispatchId: 'dispatch-1', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retained path reads only the id and handle. + resource: { id: 'resource-1', terminal_handle: handle } as never, + mode: 'recovery' + }) + + expect(receipt.state).toBe('retained') + expect(order).toEqual(['reconcile', 'observe']) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts b/src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts new file mode 100644 index 000000000000..221b97089f4a --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts @@ -0,0 +1,113 @@ +// Every host answer that carries chat tabs waits for the host's one-shot tab publication, so no +// client can apply a snapshot that lacks a listed chat and drop its saved tab (F15b). + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { SESSION_TAB_METHODS } from './session-tabs' + +afterEach(() => setStructuredAgentSessionHost(null)) + +type RestoreInternals = { + getClientSettings(): { experimentalStructuredNativeChat: boolean } + supportsAuthoritativeSessionTabsInventory(): boolean + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise +} + +/** A restarted runtime whose host install is held until `install.resolve()`. */ +function restartedRuntime() { + const install = Promise.withResolvers() + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. + const internal = runtime as unknown as RestoreInternals + internal.getClientSettings = () => ({ experimentalStructuredNativeChat: true }) + // The PTY census behind an authoritative inventory is not what this pins. + internal.supportsAuthoritativeSessionTabsInventory = () => false + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => { + await install.promise + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only these host members. + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + restoreStartupSessions: async () => undefined, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['listed-chat'] }), + listPersistedSessionTabs: () => [ + { sessionId: 'listed-chat', workspaceId: 'workspace-1', agent: 'codex' } + ] + } as never) + } + return { runtime, install } +} + +const request = (method: string, params?: unknown): RpcRequest => ({ + id: `req-${method}`, + authToken: 'tok', + method, + params +}) + +async function settle(): Promise { + for (let turn = 0; turn < 20; turn += 1) { + await Promise.resolve() + } +} + +describe('session tab answers after a restart', () => { + it('holds list, listAll, subscribe and subscribeAll until the listed chats are published', async () => { + const { runtime, install } = restartedRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const context = { + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + const answers = new Map() + const call = (method: string, params?: unknown) => { + const messages: string[] = [] + answers.set(method, messages) + return dispatcher.dispatchStreaming( + request(method, params), + (message) => messages.push(message), + context + ) + } + + const calls = [ + call('session.tabs.list', { worktree: 'id:workspace-1' }), + call('session.tabs.listAll'), + call('session.tabs.subscribe', { worktree: 'id:workspace-1' }), + call('session.tabs.subscribeAll') + ] + await settle() + for (const [method, messages] of answers) { + expect({ method, messages }).toEqual({ method, messages: [] }) + } + + install.resolve() + await vi.waitFor( + () => { + for (const [method, messages] of answers) { + expect({ method, answered: messages.length > 0 }).toEqual({ method, answered: true }) + } + }, + { timeout: 10_000 } + ) + for (const [method, messages] of answers) { + expect({ method, carries: messages[0]?.includes('agent-session:listed-chat') }).toEqual({ + method, + carries: true + }) + } + runtime.cleanupSubscriptionsByPrefix('session.tabs:') + await Promise.allSettled(calls) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts index b48dccf52a39..d3fbf649512d 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 @@ -47,6 +47,7 @@ function installHost(options: { closeThrows?: boolean; lease?: Record attached, + reconcileRestartLeases: async () => {}, hold: async () => {}, release: () => {}, subscribe: () => () => {}, diff --git a/src/main/startup/main-process-ipc-bootstrap.test.ts b/src/main/startup/main-process-ipc-bootstrap.test.ts new file mode 100644 index 000000000000..18e5aa69b8a8 --- /dev/null +++ b/src/main/startup/main-process-ipc-bootstrap.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, state } = vi.hoisted(() => { + const state: { + firstWindowStartupServicesReady: Promise + managedWslCliStartupBarrierReady: Promise + runtime: null | { prepareStructuredAgentSessionStartupRestoration: () => Promise } + } = { + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + runtime: null + } + return { handlers: new Map unknown>(), state } +}) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + } + } +})) +vi.mock('./main-process-state', () => ({ mainProcessState: state })) +vi.mock('./legacy-worker-renderer-recovery', () => ({ + recoverLegacyWorkerTerminalsForRendererStartup: vi.fn() +})) +vi.mock('./os-opened-markdown-files', () => ({ resolveOpenedMarkdownDocuments: vi.fn() })) + +import { registerMainProcessIpcHandlers } from './main-process-ipc-bootstrap' + +describe('app:prepareTerminalStartupRestoration', () => { + beforeEach(() => { + handlers.clear() + registerMainProcessIpcHandlers() + }) + + it('answers the renderer once the barriers settle while chat restoration still hangs', async () => { + const prepare = vi.fn(() => new Promise(() => {})) + state.runtime = { prepareStructuredAgentSessionStartupRestoration: prepare } + + await expect( + Promise.resolve(handlers.get('app:prepareTerminalStartupRestoration')?.()) + ).resolves.toBeUndefined() + expect(prepare).toHaveBeenCalledOnce() + }) + + it('reports a chat restoration failure without failing the renderer step', async () => { + const failure = new Error('host install failed') + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + state.runtime = { + prepareStructuredAgentSessionStartupRestoration: () => Promise.reject(failure) + } + + await expect(handlers.get('app:prepareTerminalStartupRestoration')?.()).resolves.toBeUndefined() + await vi.waitFor(() => expect(logged).toHaveBeenCalledWith(expect.any(String), failure)) + logged.mockRestore() + }) +}) diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 918fd8443640..e9e0cf81db25 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -23,7 +23,11 @@ export function registerMainProcessIpcHandlers(): void { state.firstWindowStartupServicesReady, state.managedWslCliStartupBarrierReady ]) - await state.runtime?.prepareStructuredAgentSessionStartupRestoration() + // Not awaited: chat bookkeeping must not hold the window's first paint. Every hold, attach, + // send and worker release awaits the same reconcile itself; reads need none. + void state.runtime?.prepareStructuredAgentSessionStartupRestoration().catch((error) => { + console.error('[structured-agent-session] startup restoration failed', error) + }) }) ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => recoverLegacyWorkerTerminalsForRendererStartup({ diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index a2fd133ae32e..f150fc05901b 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -281,10 +281,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) + // Not awaited: chat tabs come from the host's durable index and land whenever it answers. + // Until then the saved chat tabs stay as hydrated; the tab sync awaits this same restore. if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) { - await timeRendererStartupStep('project-structured-session-tabs', () => + void timeRendererStartupStep('project-structured-session-tabs', () => restoreLocalStructuredSessionTabsOnce() - ) + ).catch(() => {}) } if (cancelled) { return diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index fbaa404dd346..89819d1fd083 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -376,6 +376,16 @@ describe('renderer startup runtime routing', () => { ) }) + it('never holds the session writer on the structured tab projection (L13)', () => { + const source = readSource(STARTUP_HYDRATION_PATH) + const projectIndex = source.indexOf("timeRendererStartupStep('project-structured-session-tabs'") + const unlockIndex = source.indexOf('actions.setHydrationSucceeded(true)') + + expect(projectIndex).toBeGreaterThanOrEqual(0) + expect(source.slice(projectIndex - 'void '.length, projectIndex)).toBe('void ') + expect(source.slice(projectIndex, unlockIndex)).not.toContain('await ') + }) + it('probes local runtime capabilities before any startup gate can hold the answer back', () => { const source = readSource(STARTUP_HYDRATION_PATH) const probeIndex = source.indexOf('void ensureLocalRuntimeCapabilities()') diff --git a/src/renderer/src/runtime/local-structured-session-tabs-paint-window.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-paint-window.test.ts new file mode 100644 index 000000000000..997e05b1ffcc --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-paint-window.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +// The window between first paint and the host's chat-tab inventory (F15a). The session writer may +// save in it, so the saved chat tabs must still be exactly where the user left them, and must stay +// there once the inventory lands. The fake host always answers `subscribeAll` at once with a +// snapshot that lacks both chats, as a host answering before its tab publication would. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs' +import { useAppStore } from '../store' +import { + resetLocalStructuredSessionVersionForTests, + startLocalStructuredSessionTabsSync +} from './local-structured-session-tabs-sync' +import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::worktree-1' +const LEFT = 'left-group' +const RIGHT = 'right-group' +const EPOCH = 'structured:epoch-1' + +afterEach(() => { + resetLocalStructuredSessionVersionForTests() + resetWebSessionTabsSnapshotFreshnessForTests() +}) + +function tab(id: string, entityId: string, groupId: string, sortOrder: number): Tab { + const chat = id.startsWith('structured-agent-session-') + return { + id, + entityId, + groupId, + worktreeId: WORKTREE_ID, + contentType: chat ? 'agent-session' : 'terminal', + ...(chat ? { agentSessionAgent: 'codex' as const } : {}), + label: chat ? 'Codex Chat' : 'Terminal', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +/** The saved session as hydrated: a terminal and one chat on the left, a chat on the right. */ +function hydrateSavedSession(): void { + const tabs = [ + tab('terminal-1', 'terminal-1', LEFT, 0), + tab('structured-agent-session-chat-left', 'chat-left', LEFT, 1), + tab('structured-agent-session-chat-right', 'chat-right', RIGHT, 2) + ] + useAppStore.setState({ + activeWorktreeId: WORKTREE_ID, + unifiedTabsByWorktree: { [WORKTREE_ID]: tabs }, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: LEFT, + worktreeId: WORKTREE_ID, + activeTabId: 'terminal-1', + tabOrder: ['terminal-1', 'structured-agent-session-chat-left'] + }, + { + id: RIGHT, + worktreeId: WORKTREE_ID, + activeTabId: 'structured-agent-session-chat-right', + tabOrder: ['structured-agent-session-chat-right'] + } + ] + }, + layoutByWorktree: { + [WORKTREE_ID]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: LEFT }, + second: { type: 'leaf', groupId: RIGHT } + } + }, + activeGroupIdByWorktree: { [WORKTREE_ID]: RIGHT } + }) +} + +/** The tab layout the session writer would persist right now: which tabs, in which group and + * order, the split, and the active group. Field normalization an apply adds is not layout. */ +function persisted() { + const data = buildPersistedUnifiedTabSessionData(useAppStore.getState()) + return { + tabs: data.unifiedTabs?.[WORKTREE_ID]?.map(({ id, entityId, groupId, contentType }) => ({ + id, + entityId, + groupId, + contentType + })), + groups: data.tabGroups?.[WORKTREE_ID]?.map(({ id, activeTabId, tabOrder }) => ({ + id, + activeTabId, + tabOrder + })), + layout: data.tabGroupLayouts?.[WORKTREE_ID], + activeGroup: data.activeGroupIdByWorktree?.[WORKTREE_ID] + } +} + +function hostSnapshot(version: number, sessionIds: string[]): RuntimeMobileSessionTabsResult { + const ids = sessionIds.map((sessionId) => `agent-session:${sessionId}`) + return { + worktree: WORKTREE_ID, + publicationEpoch: EPOCH, + snapshotVersion: version, + activeGroupId: 'host-group', + activeTabId: ids[0] ?? null, + activeTabType: ids.length > 0 ? 'agent-session' : null, + tabGroups: [{ id: 'host-group', activeTabId: ids[0] ?? null, tabOrder: ids }], + tabs: sessionIds.map((sessionId, index) => ({ + type: 'agent-session' as const, + id: ids[index]!, + title: 'Codex Chat', + sessionId, + agent: 'codex' as const, + isActive: index === 0 + })) + } +} + +describe('chat tabs between first paint and the host inventory', () => { + it('saves the hydrated chat tabs unchanged before and after the inventory lands', async () => { + hydrateSavedSession() + const saved = persisted() + const inventory = Promise.withResolvers() + const priorApi = window.api + Object.defineProperty(window, 'api', { + configurable: true, + value: { + runtime: { + getStatus: vi.fn().mockResolvedValue({ + capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + call: vi.fn(() => inventory.promise), + subscribe: vi.fn(async (_args: unknown, callback: (response: unknown) => void) => { + callback({ + ok: true, + result: { type: 'snapshots', snapshots: [hostSnapshot(1, [])] } + }) + return { unsubscribe: vi.fn() } + }) + } + } + }) + let unsubscribe = (): void => {} + try { + const sync = startLocalStructuredSessionTabsSync({ + isDisposed: () => false, + setUnsubscribe: (next) => { + unsubscribe = next + } + }) + for (let turn = 0; turn < 20; turn += 1) { + await Promise.resolve() + } + expect(persisted()).toEqual(saved) + + inventory.resolve({ + ok: true, + result: { snapshots: [hostSnapshot(2, ['chat-left', 'chat-right'])] } + }) + await sync + + expect(persisted()).toEqual(saved) + } finally { + unsubscribe() + Object.defineProperty(window, 'api', { configurable: true, value: priorApi }) + } + }) +}) From e10362b5c7cd4408e4648da11b1a172bc9847ead Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:27:02 -0700 Subject: [PATCH 077/101] test(native-chat): a roster of idle or finished children does not keep an agent awake The sweep reads owed background work through the shared child-work liveness that upstream's release clock adopted; a child that went idle or finished is not work the agent still owes. --- .../structured-agent-session-idle-sweep.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 5c63049916df..b8a4e0390970 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -123,6 +123,20 @@ describe('the idle sweep', () => { await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) + it('stops an agent whose roster holds only children that went idle or finished', async () => { + await foundRestTestChat(rig) + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [ + { id: 'subagent-1', kind: 'agent', state: 'idle' }, + { id: 'command-1', kind: 'command', state: 'done' } + ] + }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { await foundRestTestChat(rig) providerEvents().appendItem( From c9a5576dba609c2e6e451af8f520b5990f56fc81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:22:12 -0700 Subject: [PATCH 078/101] test(native-chat): pin the startup pass's seeds, probe sidecars and first read after restart at the row they publish Assert that a restart never shows a stale or still-running row on the way to the settled one, that the probe's own sidecars are owner-only, that a worker release reads only after reconcile settles, and that the first read after a restart opens the journal by itself. --- .../journal-cursor-probe.test.ts | 13 +++- ...uctured-agent-session-startup-pass.test.ts | 72 +++++++++++++++---- ...orker-release-structured-reconcile.test.ts | 3 +- ...ss-version-agent-session-wire.unit.test.ts | 3 + 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts b/src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts index ca1f533465b4..e9bce1a81027 100644 --- a/src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-cursor-probe.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, copyFileSync, existsSync, mkdirSync, @@ -57,14 +58,22 @@ describe('journal cursor probe', () => { const journalDir = tempDir() const cursor = await settledJournal(journalDir) const dbPath = journalDatabaseFile(journalDir) + const sidecars = [`${dbPath}-wal`, `${dbPath}-shm`] + // As a cleanly closed journal leaves it: the main file alone. Its mode loosened, as a copied or + // restored profile can leave it, because SQLite gives new sidecars the main file's mode. + sidecars.forEach((path) => rmSync(path, { force: true })) + if (process.platform !== 'win32') { + chmodSync(dbPath, 0o644) + } const before = readFileSync(dbPath) expect(probeJournalCursor(journalDir, SESSION)).toEqual(cursor) expect(readFileSync(dbPath).equals(before)).toBe(true) if (process.platform !== 'win32') { - // A read-only open of a WAL file may leave sidecars behind; they stay owner-only. - for (const sidecar of [`${dbPath}-wal`, `${dbPath}-shm`].filter((path) => existsSync(path))) { + // A read-only open of a WAL file leaves its sidecars behind; they stay owner-only. + expect(sidecars.filter((path) => existsSync(path))).not.toEqual([]) + for (const sidecar of sidecars.filter((path) => existsSync(path))) { expect(statSync(sidecar).mode & 0o777).toBe(0o600) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts index 5aa605781974..f2755ed2a2fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts @@ -5,7 +5,10 @@ import { existsSync } from 'node:fs' import { rm, writeFile } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION } from '../../../shared/structured-agent-session-saved-status' +import { + STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + type StructuredAgentSessionSavedStatus +} from '../../../shared/structured-agent-session-saved-status' import { AgentSessionSavedStatusStore } from '../../runtime/agent-session-saved-status-store' import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' import { HOST_TEST_NOW } from './structured-agent-session-host-test-data' @@ -28,18 +31,43 @@ afterEach(async () => { await rig.dispose() }) +/** Every row the current run published for a session, in order. */ +function rowsOf(sessionId: string) { + return rig.statusEvents.flatMap((event) => + event.type === 'status' && event.session.sessionId === sessionId + ? [event.session] + : event.type === 'snapshot' + ? event.sessions.filter((session) => session.sessionId === sessionId) + : [] + ) +} + function journalDir(sessionId: string): string { return journalDirectoryFor(rig.root, { workspaceId: 'workspace-1', sessionId }) } /** A second run's send, then a crash with the turn still running and its child alive. */ async function crashMidTurn(sessionId: string, text: string): Promise { - const host = await rig.boot() + const recorded: StructuredAgentSessionSavedStatus[] = [] + const host = await rig.boot({ + savedStatus: { + read: (id) => rig.savedStatus.read(id), + prune: (keep) => rig.savedStatus.prune(keep), + record: (id, saved) => { + recorded.push(saved) + rig.savedStatus.record(id, saved) + } + } + }) await host.restoreStartupSessions() await rig.chat(sessionId, { message: text }) - // The flush timer fired: whatever the running turn left in the saved copy is on disk. + // Every publish of the running turn has run, and the flush timer fired: whatever it left in the + // saved copy is on disk. + await new Promise((resolve) => setTimeout(resolve, 0)) rig.savedStatus.flush() await rig.crash() + // Only a settled chat is ever saved: a running turn writes nothing. + expect(recorded.filter((saved) => saved.projection.status === 'working')).toEqual([]) } describe('listing from a saved status', () => { @@ -136,22 +164,38 @@ describe('listing from a saved status', () => { status: 'idle', latestPrompt: 'second question' }) + // Never shown in between: neither the settled turn before it nor the turn as still running. + expect(rowsOf('session-a').filter((row) => row.latestPrompt !== 'second question')).toEqual([]) + expect(rowsOf('session-a').filter((row) => row.status === 'working')).toEqual([]) expect(rig.store.getRecord('session-a')?.lease.settlementRetryRequired).toBeUndefined() }) - it('lists what a quit mid-turn settled, never the finished turn before it (C10)', async () => { - await rig.chat('session-a', { message: 'first question' }) - await rig.quit() - const second = await rig.boot() - await second.restoreStartupSessions() - await rig.chat('session-a', { message: 'second question' }) - await rig.quit() - const host = await rig.boot() + it.each([ + ['saved', true], + ['lost', false] + ])( + 'lists what a quit mid-turn settled, never the finished turn before it, its save %s (C10)', + async (_case, saves) => { + await rig.chat('session-a', { message: 'first question' }) + await rig.quit() + // A run whose saved-status writes never land: its entry stays at the first turn's position. + const dropped = { + read: (id: string) => rig.savedStatus.read(id), + prune: (keep: (id: string) => boolean) => rig.savedStatus.prune(keep), + record: () => {} + } + const second = await rig.boot(saves ? {} : { savedStatus: dropped }) + await second.restoreStartupSessions() + await rig.chat('session-a', { message: 'second question' }) + await rig.quit() + const host = await rig.boot() - await host.restoreStartupSessions() + await host.restoreStartupSessions() - expect(latestStatus(rig, 'session-a')).toMatchObject({ latestPrompt: 'second question' }) - }) + expect(latestStatus(rig, 'session-a')).toMatchObject({ latestPrompt: 'second question' }) + expect(rowsOf('session-a').filter((row) => row.latestPrompt === 'first question')).toEqual([]) + } + ) it('drops saved copies of chats that no longer exist (C11)', async () => { await rig.chat('session-a', { message: 'first question' }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts index 99a422597c87..359d27c30502 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-structured-reconcile.test.ts @@ -29,7 +29,8 @@ describe('structured worker release after a restart', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release reads only the reconcile stubbed here. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => { - await Promise.resolve() + // A reconcile that probes processes settles after a macrotask, not a microtask. + await new Promise((resolve) => setTimeout(resolve, 0)) order.push('reconcile') } } as never) 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 d0c12fa31215..7af97b7139a0 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 @@ -721,6 +721,9 @@ describe('cross-version structured agent sessions', () => { // What every boot runs before its startup pass; it opens no journal. The hold and the // reads open it on first use, and the hold gives the session a child again. await restarted.reconcileRestartLeases() + // The first read after the restart opens the journal on its own. + const reread = await answer('agentSession.history', { sessionId: SESSION, direction: 'tail' }) + expect(JSON.stringify(reread)).toContain('before restart') await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' }) const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(resumedFence).toBeGreaterThan(created.fence) From 20978d60e845b325ccad80dc4979252cd3826986 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:34:39 -0700 Subject: [PATCH 079/101] fix(orchestration): a task dispatched into a resting structured worker keeps it running The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's process incarnation now counts, derived from the existing rows. --- .../worker-terminal-transfer.ts | 23 ++++-- .../runtime/structured-worker-at-rest.test.ts | 70 +++++++++++++++++++ .../runtime/structured-worker-identity.ts | 4 +- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts index f46687bee941..e478f3d736c6 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts @@ -81,8 +81,9 @@ export function workerTerminalResourceHasIdentityConflict( ) } -/** Whether a dispatch that has not settled still owns the terminal of this process, read through - * the resource row so a transferred terminal answers for its current owner. */ +/** Whether a dispatch that has not settled still addresses this process: the worker-start + * dispatch that owns its terminal, read through the resource row so a transferred terminal + * answers for its current owner, or any task later dispatched to the same incarnation. */ export function hasOpenWorkerDispatchForProcess( this: OrchestrationDb, params: { processIncarnation: string; hostScope: string } @@ -94,10 +95,20 @@ export function hasOpenWorkerDispatchForProcess( WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned'` ) .all(params.processIncarnation, params.hostScope) as { owner_dispatch_id: string }[] - return owners.some((row) => - ['starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown'].includes( - this.getWorkerDispatch(row.owner_dispatch_id)?.state ?? '' - ) + return ( + owners.some((row) => + ['starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown'].includes( + this.getWorkerDispatch(row.owner_dispatch_id)?.state ?? '' + ) + ) || + // Only for a process this host owns a worker terminal for; the resource row carries the scope. + (owners.length > 0 && + this.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .get(params.processIncarnation) !== undefined) ) } diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index f8c96c364eb5..c87871233b3b 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -29,6 +29,8 @@ const { listAddressableStructuredWorkers } = await import('./orchestration/structured-worker-group-addressing') const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') const { resolveGroupAddress } = await import('./orchestration/groups') +const { createRestTestRig, foundRestTestChat, IDLE_MS, REST_TEST_SESSION, sweepTicks } = + await import('../native-chat/agent-session-wire/structured-agent-session-rest-test-rig') const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' const LOCAL = JSON.stringify({ kind: 'local', hostId: 'local' }) @@ -258,3 +260,71 @@ describe('a close whose stop could not be proven (P2-30)', () => { expect(structuredSessionCloseSettled(SESSION)).toBe(false) }) }) + +describe('a task dispatched into a worker whose own dispatch settled', () => { + it('keeps the worker running while that task is open, and lets it rest once the task settles', async () => { + const db = new OrchestrationDb(':memory:') + const rig = await createRestTestRig({ + hasOpenDispatch: (current) => structuredWorkerHasOpenDispatch(db, current) + }) + try { + hostRef.current = rig.host + await foundRestTestChat(rig) + const incarnation = structuredWorkerProcessIncarnation(REST_TEST_SESSION) + const handle = mintStructuredWorkerHandle() + const workerPane = mintStructuredWorkerPaneKey(REST_TEST_SESSION) + const first = db.createTask({ runId: 'run_legacy_local', spec: 'first task' }) + const { dispatch: started } = db.createStartingWorkerDispatch({ + taskId: first.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.id, + handle, + paneKey: workerPane, + processIncarnation: incarnation, + worktreeId: 'wt_1', + effects: [], + setupState: 'not_configured', + hostScope: LOCAL, + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.id) + expect( + db.settleWorkerReport({ + taskId: first.id, + dispatchId: started.id, + outcome: 'succeeded', + result: 'done' + }) + ).toMatchObject({ action: 'settled' }) + // The coordinator hands the same worker its next task: a dispatch with no worker row. + const second = db.createTask({ runId: 'run_legacy_local', spec: 'second task' }) + const handedOn = db.createDispatchContext({ + taskId: second.id, + assigneeHandle: handle, + assigneePaneKey: workerPane, + processIncarnation: incarnation, + creator: { kind: 'system' }, + maxDepth: 9 + }) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('live') + + db.completeDispatch(handedOn.id) + await vi.waitFor(() => + expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') + ) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + } finally { + hostRef.current = null + await rig.dispose() + db.close() + } + }) +}) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index d7aa6194fb97..717ad06569ed 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -160,8 +160,8 @@ export function structuredWorkerRecordIsCurrent( ) } -/** Whether a dispatch that has not settled still owns this session's worker, keyed the way the - * worker's terminal resource row is: its process incarnation and host scope. */ +/** Whether a dispatch that has not settled still addresses this session's worker, keyed the way + * the worker's terminal resource row is: its process incarnation and host scope. */ export function structuredWorkerHasOpenDispatch( db: OrchestrationDb | null, record: AgentSessionRecord From d494b10f5d010ecefbeb661b50e2cddc39d08070 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:46:56 -0700 Subject: [PATCH 080/101] fix(native-chat): seed a store with no tab index from the saved session at open A store written before any tab index listed its chats only in the saved workspace session, and the first single visibility write created a tab table holding that one chat, so every other saved chat left the restore for good. The store now seeds its tab table from the saved session's chat tabs when it opens without one, reaching disk with the first write as every other load-time repair does. With a table always present after open, "is there an index" is no longer a question: readers ask only which chats it lists, and the restore's per-store fallback to the saved session is gone. --- ...red-agent-session-conversation-lifetime.ts | 7 +-- .../structured-agent-session-host.ts | 2 +- ...ent-session-record-store-tab-table.test.ts | 47 ++++++++++++++++++- .../runtime/agent-session-record-store.ts | 19 ++++---- src/main/runtime/agent-session-tab-table.ts | 22 +++++++++ ...time-close-structured-agent-session-tab.ts | 2 +- .../runtime/orca-runtime-get-worktree-ps.ts | 5 ++ ...tore-structured-agent-session-tabs-once.ts | 25 +--------- ...runtime-structured-session-restore.test.ts | 39 ++------------- ...structured-worker-group-addressing.test.ts | 5 +- ...ration-structured-worker-lifecycle.test.ts | 5 +- .../session-tabs-listing-order.test.ts | 2 +- .../methods/structured-chat-tab-table.test.ts | 7 +-- ...ent-session-rollback-compatibility.test.ts | 28 +++++++---- .../structured-agent-session-runtime.ts | 5 +- ...gent-session-unpublished-tab-close.test.ts | 2 +- ...ructured-session-worktree-teardown.test.ts | 2 +- .../runtime/structured-worker-at-rest.test.ts | 2 +- .../runtime/structured-worker-authority.ts | 2 +- .../structured-worker-mail-routing.test.ts | 5 +- 20 files changed, 126 insertions(+), 107 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index bd9f95de0bb3..10710a1935b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -48,11 +48,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { closeStructuredAgentSessionConversationUnderSerialize( { sessions, - closeStatus: (id) => { - const tabs = deps().store.getVisibleSessionTabIndex() - // A legacy store cannot say, so the row stays; restart is the boundary that forgets. - host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) - } + closeStatus: (id) => + host.closeStatus(id, { listed: deps().store.listVisibleSessionIds().includes(id) }) }, 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 5160bccbe457..69a82c074902 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 @@ -208,7 +208,7 @@ export class StructuredAgentSessionHost { /** The tabs the durable index lists, from records alone: no journal is read. */ listPersistedSessionTabs = (sessionIds: readonly string[]) => sessionTabs.listPersistedStructuredAgentSessionTabs(this.deps, sessionIds) - getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() + listVisibleSessionIds = (): string[] => this.deps.store.listVisibleSessionIds() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) setSessionTabVisibility = async ( diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index e0ecfec28358..227bb7a88f73 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -73,7 +73,8 @@ describe('chat tab table', () => { await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) // A create that dies before its tab is shown leaves nothing to restore or release. expect(store.getSessionTabId('session-alpha')).toBeNull() - expect((await readFileJson()).sessionTabs).toBeUndefined() + // The table exists from the store's first open, and lists nothing. + expect((await readFileJson()).sessionTabs).toEqual([]) await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') expect(store.getSessionTabId('session-alpha')).toBe('tab-alpha') @@ -170,6 +171,50 @@ describe('chat tab table', () => { expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') }) + it('seeds a store with no index from the saved session, so one later write cannot drop the rest (L4)', async () => { + const first = await open() + for (const [index, sessionId] of ['session-alpha', 'session-beta', 'session-gamma'].entries()) { + await first.reserveOwner( + reserveRequest({ + sessionId, + operation: { + callerKey: 'client-1', + operationId: operationId(), + fingerprint: `fp-${index}` + } + }) + ) + } + // What a build before any tab index wrote: chats listed only in the saved workspace session. + const raw = await readFileJson() + delete raw.sessionTabs + delete raw.visibleSessionIds + const legacy = JSON.stringify(raw) + await writeFile(filePath(), legacy) + const saved = ['session-alpha', 'session-beta', 'session-gamma', 'session-without-record'] + + const seeded = await AgentSessionRecordStore.open({ + directory, + hostId: 'local', + savedTabSessionIds: () => saved + }) + expect(seeded.listVisibleSessionIds()).toEqual([ + 'session-alpha', + 'session-beta', + 'session-gamma' + ]) + expect(await readFile(filePath(), 'utf-8')).toBe(legacy) + + await seeded.setSessionTabVisibility('session-beta', false) + expect((await readFileJson()).visibleSessionIds).toEqual(['session-alpha', 'session-gamma']) + const restarted = await AgentSessionRecordStore.open({ + directory, + hostId: 'local', + savedTabSessionIds: () => saved + }) + expect(restarted.listVisibleSessionIds()).toEqual(['session-alpha', 'session-gamma']) + }) + it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { const first = await open() for (const [index, sessionId] of ['session-alpha', 'clear-one', 'clear-two'].entries()) { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 445260cc47b4..868f83f4a82c 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -70,7 +70,7 @@ import { agentSessionStorePath, type AgentSessionStoreState } from './agent-session-record-store-file' -import { setAgentSessionTabVisibility } from './agent-session-tab-table' +import { seedAgentSessionTabTable, setAgentSessionTabVisibility } from './agent-session-tab-table' import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' import { AgentSessionStoreTransactionQueue, @@ -83,7 +83,12 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, export class AgentSessionRecordStore { private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} - static async open(args: { directory: string; hostId: string }): Promise { + static async open(args: { + directory: string + hostId: string + /** The saved workspace session's chat tabs, for a store that predates any tab index. */ + savedTabSessionIds?: () => readonly string[] + }): Promise { const filePath = agentSessionStorePath(args.directory) const loaded = await loadProtectedAgentSessionStore(filePath, args.hostId) // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart @@ -93,10 +98,13 @@ export class AgentSessionRecordStore { // write here: a rewrite at open would read as an external change to any other holder of the // file mid-restart. markAgentSessionStoreLeasesUnreconciled(loaded.state) + // After the revision for the same reason: the seeded table reaches disk with the first write. + const seeded = seedAgentSessionTabTable(loaded.state, args.savedTabSessionIds?.() ?? []) + const needsRewrite = loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized || seeded const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( filePath, args.hostId, - { ...loaded, needsRewrite: loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized }, + { ...loaded, needsRewrite }, diskRevision ) if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { @@ -131,11 +139,6 @@ export class AgentSessionRecordStore { this.state.records.has(sessionId) ) - getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ - present: this.state.sessionTabs !== null, - sessionIds: this.listVisibleSessionIds() - }) - /** The id of the chat tab showing this conversation, if one does. */ getSessionTabId = (sessionId: string): string | null => this.state.sessionTabs?.tabIdFor(sessionId) ?? null diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 3855d4319895..e1fa848ec864 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -130,6 +130,28 @@ export function setAgentSessionTabVisibility( } } +/** + * A store written before any tab index lists its chats only in the saved workspace session. The + * table is seeded from that session once, at open, so no later single write can create a table + * holding one chat and drop the rest. True when it seeded. + */ +export function seedAgentSessionTabTable( + state: AgentSessionStoreState, + savedTabSessionIds: readonly string[] +): boolean { + if (state.sessionTabs !== null) { + return false + } + const table = new AgentSessionTabTable() + for (const sessionId of savedTabSessionIds) { + if (state.records.has(sessionId)) { + table.show(sessionId) + } + } + state.sessionTabs = table + return true +} + export type PersistedAgentSessionTab = { tabId: string; sessionId: string } export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 4685ba1dfd00..7e3666220330 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -53,7 +53,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi // Only a chat the index still lists, in the workspace the close names; anything else is unknown. if ( record?.location?.workspaceId === args.worktreeId && - host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) === true + host.listVisibleSessionIds?.().includes(sessionId) === true ) { await this.closeStructuredAgentSessionTab({ type: 'agent-session', diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index d72c250b0976..e8a244076b24 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -18,6 +18,7 @@ import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/fi import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { structuredWorkerHasOpenDispatch } from './structured-worker-identity' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' @@ -141,6 +142,10 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis await installStructuredAgentSessionHost({ stateDirectory: getProfileUserDataPath(), hostId: LOCAL_EXECUTION_HOST_ID, + savedTabSessionIds: () => + collectSavedStructuredAgentSessionIds( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ), claimKeyId: this.agentSessionClaimSigner.keyId, // Resolves folder workspaces as well as git worktrees, so a chat session // in a plain folder lands in the folder rather than failing to resolve. diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index baaeb8a4513c..7d56de23d6e9 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -3,8 +3,6 @@ import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-ru import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' -import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionTabsSnapshot, RuntimeRepoSearchRefs @@ -59,7 +57,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }) } const host = getStructuredAgentSessionHost() - await this.adoptLegacyStructuredAgentSessionTabs(host) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -73,35 +70,17 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.publishListedStructuredAgentSessionTabs(host) } - /** A store that predates the tab index lists its chats only in the saved workspace session. Each - * is written into the index, as the per-tab publish always did, so the first other write to the - * index cannot leave it holding one chat. */ - private async adoptLegacyStructuredAgentSessionTabs(host): Promise { - if (typeof host?.getPersistedVisibleSessionTabIndex !== 'function') { - return - } - if (host.getPersistedVisibleSessionTabIndex().present) { - return - } - const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null - for (const sessionId of collectSavedStructuredAgentSessionIds(saved)) { - await host.setSessionTabVisibility(sessionId, true).catch(() => undefined) - } - } - /** Every listed chat in one store write per workspace, from the index and the records alone. * Synchronous from the index read to the last store, so a close lands wholly before or after. */ private publishListedStructuredAgentSessionTabs(host): void { if ( - typeof host?.getPersistedVisibleSessionTabIndex !== 'function' || + typeof host?.listVisibleSessionIds !== 'function' || typeof host.listPersistedSessionTabs !== 'function' ) { return } const byWorkspace = new Map() - for (const tab of host.listPersistedSessionTabs( - host.getPersistedVisibleSessionTabIndex().sessionIds - )) { + for (const tab of host.listPersistedSessionTabs(host.listVisibleSessionIds())) { if (tab.agent !== 'codex' && tab.agent !== 'claude') { continue } 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 6ec4653ddba3..638bfbeece0b 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -113,7 +113,7 @@ describe('structured session cold restoration', () => { reconcileRestartLeases, restoreStartupSessions, setSessionTabVisibility, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['a', 'b', 'c'] }), + listVisibleSessionIds: () => ['a', 'b', 'c'], listPersistedSessionTabs: (ids: readonly string[]) => ids.map((sessionId) => ({ sessionId, @@ -157,7 +157,7 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreStartupSessions: async () => undefined, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), + listVisibleSessionIds: () => [], listPersistedSessionTabs } as never) @@ -166,37 +166,6 @@ describe('structured session cold restoration', () => { expect(listPersistedSessionTabs).toHaveBeenCalledExactlyOnceWith([]) }) - it('writes a store without an index every chat its saved session shows', async () => { - const runtime = new OrcaRuntimeService() - const index: { present: boolean; sessionIds: string[] } = { present: false, sessionIds: [] } - const setSessionTabVisibility = vi.fn(async (sessionId: string) => { - index.present = true - index.sessionIds.push(sessionId) - }) - const listPersistedSessionTabs = vi.fn(() => []) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these members exist on the runtime; they are protected, not absent. - const internal = runtime as unknown as RestoreInternals - internal.store = { getWorkspaceSession: () => savedSessionWithChat('legacy-session') } - internal.hasPersistedStructuredAgentSessionStore = () => true - internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() - internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() - internal.refreshMobileSessionPtyRecords = async () => new Set() - internal.ensureStructuredAgentSessionHost = async () => undefined - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only the host members stubbed here. - setStructuredAgentSessionHost({ - reconcileRestartLeases: async () => undefined, - restoreStartupSessions: async () => undefined, - setSessionTabVisibility, - getPersistedVisibleSessionTabIndex: () => index, - listPersistedSessionTabs - } as never) - - await runtime.restoreStructuredAgentSessionTabs() - - expect(setSessionTabVisibility).toHaveBeenCalledExactlyOnceWith('legacy-session', true) - expect(listPersistedSessionTabs).toHaveBeenCalledExactlyOnceWith(['legacy-session']) - }) - it('normalizes a restored tab id and removes it when closed', async () => { const runtime = new OrcaRuntimeService() const closeSessionTab = vi.fn(async () => undefined) @@ -224,7 +193,7 @@ describe('structured session cold restoration', () => { restoreStartupSessions: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['restored'] }), + listVisibleSessionIds: () => ['restored'], listPersistedSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -335,7 +304,7 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreStartupSessions: async () => undefined, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['restored'] }), + listVisibleSessionIds: () => ['restored'], listPersistedSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 2f7b0a49eaf9..29013cecc7c2 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -66,10 +66,7 @@ function installHost(options: { } }, hasSession: () => options.hasSession ?? true, - getPersistedVisibleSessionTabIndex: () => ({ - present: true, - sessionIds: options.tabListed ? [SESSION_ID] : [] - }), + listVisibleSessionIds: () => (options.tabListed ? [SESSION_ID] : []), journalSnapshot: async () => ({ items: options.items ?? [idleTurn()] }) } } diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts index b669233c31fb..affc8646e32d 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts @@ -64,10 +64,7 @@ function installHost(options: { let closed = false hostRef.current = { deps: { store: { getRecord: () => record } }, - getPersistedVisibleSessionTabIndex: () => ({ - present: true, - sessionIds: options.tabListed ? [IDENTITY.sessionId] : [] - }), + listVisibleSessionIds: () => (options.tabListed ? [IDENTITY.sessionId] : []), hasSession: () => (closed ? false : (options.hasSession ?? true)), setSessionTabVisibility: options.setSessionTabVisibility ?? (async () => {}), close: diff --git a/src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts b/src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts index 221b97089f4a..0ad7322fd13f 100644 --- a/src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-listing-order.test.ts @@ -40,7 +40,7 @@ function restartedRuntime() { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreStartupSessions: async () => undefined, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['listed-chat'] }), + listVisibleSessionIds: () => ['listed-chat'], listPersistedSessionTabs: () => [ { sessionId: 'listed-chat', workspaceId: 'workspace-1', agent: 'codex' } ] diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index f76b3ae6cb74..7cf91e754513 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -277,10 +277,7 @@ describe('a chat tab across /clear', () => { await openHost() expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - expect(host.getPersistedVisibleSessionTabIndex()).toEqual({ - present: true, - sessionIds: [replacement] - }) + expect(host.listVisibleSessionIds()).toEqual([replacement]) }) it('gives a reopened cleared conversation the same id when an older build drops the table', async () => { @@ -387,7 +384,7 @@ describe('a create that reserves its tab', () => { await host.flushAllStreamedEvents() await openHost() - expect(host.getPersistedVisibleSessionTabIndex().sessionIds).toEqual([]) + expect(host.listVisibleSessionIds()).toEqual([]) expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ ok: true, value: { tabId: 'reserved-tab' } diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index 293ec194aae7..733a7cf42b55 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -99,17 +99,22 @@ describe('structured session rollback compatibility', () => { await mkdir(journalDir, { recursive: true }) await writeFile(join(journalDir, 'journal.log'), 'durable-journal-fixture\n') - const target = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) - expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) - await target.setSessionTabVisibility(SESSION, true) - expect(target.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [SESSION] }) - const targetProfile = profileWithStructuredTab() const targetParsed = safeParseWorkspaceSession(targetProfile) expect(targetParsed?.success).toBe(true) - expect( - collectSavedStructuredAgentSessionIds(targetParsed?.success ? targetParsed.data : null) - ).toEqual([SESSION]) + const targetTabs = collectSavedStructuredAgentSessionIds( + targetParsed?.success ? targetParsed.data : null + ) + expect(targetTabs).toEqual([SESSION]) + + // The store predates any tab index: it is seeded from the saved session at open (L4b). + const target = await AgentSessionRecordStore.open({ + directory: storeDir, + hostId: 'local', + savedTabSessionIds: () => targetTabs + }) + expect(target.listVisibleSessionIds()).toEqual([SESSION]) + await target.setConversationName(SESSION, 'first write persists the seed') const baseProfile = pinnedBaseRoundTrip(targetProfile) const baseParsed = safeParseWorkspaceSession(baseProfile) @@ -126,7 +131,11 @@ describe('structured session rollback compatibility', () => { targetReloadedProfile?.success ? targetReloadedProfile.data : null ) ).toEqual([]) - const reloaded = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const reloaded = await AgentSessionRecordStore.open({ + directory: storeDir, + hostId: 'local', + savedTabSessionIds: () => [] + }) expect(reloaded.listVisibleSessionIds()).toEqual([SESSION]) expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( @@ -138,7 +147,6 @@ describe('structured session rollback compatibility', () => { await reloaded.setSessionTabVisibility(SESSION, false) const afterClose = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) - expect(afterClose.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [] }) expect(afterClose.listVisibleSessionIds()).toEqual([]) expect(afterClose.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index befe3be860d0..c678ab49a6dc 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -75,6 +75,8 @@ export type StructuredAgentSessionRuntimeDeps = { hostId: string /** Key id this host's claims are minted under. */ claimKeyId: string + /** The saved workspace session's chat tabs; seeds a store that predates any tab index. */ + savedTabSessionIds?: () => readonly string[] resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string @@ -198,7 +200,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { diff --git a/src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts b/src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts index f278ecb90e9d..77c30d4a5536 100644 --- a/src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts +++ b/src/main/runtime/structured-agent-session-unpublished-tab-close.test.ts @@ -36,7 +36,7 @@ function runtimeInstallingHost(listed: readonly string[]) { : null } }, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...listed] }), + listVisibleSessionIds: () => [...listed], setSessionTabVisibility, close } as never) diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index 602aaa3d3aa0..c46ba8575f80 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -97,7 +97,7 @@ function installHost(options: { } }, hasSession: (sessionId: string) => held.has(sessionId), - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }), + listVisibleSessionIds: () => [...visible], setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => { if (!isVisible) { visible.delete(sessionId) diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index f8c96c364eb5..656aeaef631e 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -52,7 +52,7 @@ function installHost(current: AgentSessionRecord | null, tabs: string[]) { const host = { deps: { store: { getRecord: () => current } }, hasSession: () => false, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: tabs }), + listVisibleSessionIds: () => tabs, setSessionTabVisibility: vi.fn(async (_id: string, visible: boolean) => { tabs.splice(0, tabs.length, ...(visible ? [SESSION] : [])) }), diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index dd1595cea4bc..baf712fae520 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -74,7 +74,7 @@ function structuredWorkerTabListed( sessionId: string ): boolean { try { - return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + return host.listVisibleSessionIds?.().includes(sessionId) ?? false } catch { return false } diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts index 0413c7b6fa64..6f10b29a87c0 100644 --- a/src/main/runtime/structured-worker-mail-routing.test.ts +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -26,10 +26,7 @@ function installRecord( ): void { hostRef.current = lease ? { - getPersistedVisibleSessionTabIndex: () => ({ - present: true, - sessionIds: tabListed ? [SESSION_ID] : [] - }), + listVisibleSessionIds: () => (tabListed ? [SESSION_ID] : []), deps: { store: { getRecord: () => ({ From 946170e8e1c85661667392ea7d7d5c734f77a9bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:49:22 -0700 Subject: [PATCH 081/101] test(native-chat): state why the worker release stub's host cast is safe --- .../runtime/rpc/methods/structured-worker-tab-retirement.test.ts | 1 + 1 file changed, 1 insertion(+) 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 d3fbf649512d..a8b8d4a07c93 100644 --- a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -43,6 +43,7 @@ function installHost(options: { closeThrows?: boolean; lease?: Record Date: Fri, 25 Sep 2026 23:54:01 -0700 Subject: [PATCH 082/101] docs(native-chat): comments stop describing the hold this PR removed Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it. Comment-only. --- ...rchestration-structured-worker-lifecycle.ts | 2 +- .../orchestration-structured-worker-session.ts | 4 ++-- .../worker/failed-worker-start-teardown.ts | 8 ++++---- .../orchestration/worker/worker-control.ts | 5 ++--- .../worker/worker-release-completion.ts | 5 ++--- .../runtime/structured-agent-session-close.ts | 8 ++++---- .../structured-session-worktree-teardown.ts | 18 +++++++----------- .../use-structured-agent-session-transport.ts | 6 ++---- 8 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index c15fafaf1082..c6bb3d53d583 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -84,7 +84,7 @@ export async function stopStructuredWorker( return closeStructuredAgentSessionChild(identity.sessionId, { ...(runtime ? { runtime } : {}), // Between the close and the proof, never after: an unsettled close returns early, and a - // surviving hold keeps the provider child un-evictable for the life of the app. + // surviving redrive subscription keeps nudging a session no dispatch owns. afterClose: () => releaseStructuredWorkerSession(dispatchId, runtime) }) } 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 fb2ad33f2857..999445bf2d0b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -153,8 +153,8 @@ export async function createStructuredWorkerSession(args: { * * `ok` is not the test. `commit` answers `agent_session_operation_unknown` when `attach` SUCCEEDED * and only the tab publish failed, and a throw out of the commit half is past `attach` too — the - * pre-commit half never throws, it refuses. Both leave a live provider child that took no hold and - * has no binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal + * pre-commit half never throws, it refuses. Both leave a session with a published tab and no + * binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal * proves there is nothing to discard; everything else gets the best-effort close. */ function structuredCreateMayHaveCommitted( diff --git a/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts b/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts index 250b639fc603..758bc3397cb7 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts @@ -8,10 +8,10 @@ import type { createStructuredWorkerSessionForWorktree } from './worker-topology /** * Undoes what a start created before it failed. * - * A start that never reached ready leaves no settlement to release the hold later, and its session - * was already published as a chat tab — without the discard, a failed start strands a dead chat tab - * that the durable restore index republishes on every app launch. Both halves are best-effort by - * construction, so neither can replace the real error. + * A start that never reached ready leaves no settlement to release its binding later, and its + * session was already published as a chat tab — without the discard, a failed start strands a dead + * chat tab that the durable restore index republishes on every app launch. Both halves are + * best-effort by construction, so neither can replace the real error. * * A created PTY terminal is deliberately NOT torn down: its custody row was written at creation, so * `worker-release` on the failed Dispatch owns that cleanup and the coordinator decides when. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 7f150493f150..3187d55cccfc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -200,9 +200,8 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ const abandoned = runtime.getOrchestrationDb().abandonWorkerDispatch(params.dispatch) if (abandoned.disposition === 'context_only') { if (!abandoned.alreadySettled) { - // Abandon settles the Dispatch, so it owes the same hold release stop and release do. - // A surviving hold pins the provider child for the life of the app and makes host crash - // recovery respawn a worker nobody is waiting on. + // Abandon settles the Dispatch, so it owes the same binding release stop and release do: + // a surviving redrive subscription keeps nudging a worker nobody is waiting on. releaseStructuredWorkerSession(params.dispatch, runtime) runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts index b686cdfe6cd4..0ae55f8a9386 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-completion.ts @@ -98,9 +98,8 @@ async function completeWorkerTerminalReleaseOnce( // here is what lets the release see the session instead of reporting it unreadable. // // NOT yet handled, and deliberately follow-up: rebinding a restarted runtime to a structured - // worker's hold and redrive subscription. Until that exists, a worker that survives a restart - // keeps no hold, so its child is evictable and its parked mail waits for the next arrival - // rather than a settle edge. + // worker's redrive subscription. Until that exists, a worker that survives a restart has its + // parked mail wait for the next arrival rather than a settle edge. await runtime.ensureStructuredAgentSessionHost().catch((error: unknown) => { console.warn( '[orchestration] structured host install failed before release', diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index dbb0c39ee5c0..668b7f218903 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -3,8 +3,8 @@ * * Extracted from `stopStructuredWorker` so that orchestration settlement and worktree teardown * close a session the SAME way rather than one of them inventing a shorter version. Everything - * dispatch-shaped — dropping the hold, the redrive subscription and the parked mail — stays with - * the caller that has a dispatch; this is only the child. + * dispatch-shaped — dropping the redrive subscription, the registry entry and the parked mail — + * stays with the caller that has a dispatch; this is only the child. * * `host.close` returns void and keeps a failed close indexed for retry, so the only settlement * evidence is the observation AFTER it: a session the host no longer holds and whose lease is no @@ -35,8 +35,8 @@ export type StructuredAgentSessionCloseOptions = { /** * Runs after the close is issued and BEFORE the proof is read. * - * Not after: an unsettled close returns early, so a dispatch that released its hold there would - * keep the child un-evictable for the life of the app. Every settlement has to reach it. + * Not after: an unsettled close returns early, so a dispatch released there would keep its + * redrive subscription nudging a session no dispatch owns. Every settlement has to reach it. */ afterClose?: () => void /** diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 5f1413c83870..3f8ab31b3ec7 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -89,12 +89,9 @@ export function structuredSessionTeardownHostId( * The workspace's structured sessions, split into what belongs to it and what is attached. * * MEMBERSHIP and LIVENESS answer different questions, and folding them into one list is what let - * a chat tab outlive its workspace. A provider child is scoped to a VISIBLE pane — the hold that - * keeps one is `enabled: isVisible && isWorktreeActive`, and dropping the last hold evicts the - * child after the release grace — so `live` really means "this chat is the visible pane in the - * active workspace, or was moments ago". Deleting a workspace from the sidebar while a different - * one is active makes every chat in the target non-live. Those are exactly the sessions a - * liveness-only list never saw. + * a chat tab outlive its workspace. A provider child runs from a send until the idle sweep rests + * it, so `live` only means "this chat's agent worked recently", and every chat at rest in the + * target is non-live. Those are exactly the sessions a liveness-only list never saw. */ export type StructuredSessionsForWorktree = { /** Every session bound to this workspace on the fenced host, attached or not. */ @@ -259,11 +256,10 @@ export async function closeStructuredSessionsForWorktree( } = {} ): Promise { const { runtime, mayRefuse } = options - // No `afterClose` for a dispatched worker: `host.close` drops the holds, so nothing keeps a - // provider child un-evictable, but the dispatch's redrive subscription and registry entry do - // survive until it settles by another verb. That is a bounded leak, not a hazard — and passing - // one here would mean resolving a dispatch id per session on a teardown path that must stay - // inside the sweep deadline. + // No `afterClose` for a dispatched worker: `host.close` stops the child, but the dispatch's + // redrive subscription and registry entry survive until it settles by another verb. That is a + // bounded leak, not a hazard — and passing one here would mean resolving a dispatch id per + // session on a teardown path that must stay inside the sweep deadline. for (const session of progress.sessions) { // Stops ISSUING new closes once the budget is spent; an in-flight one is left to finish, since // nothing here can cancel a provider round trip. Without this, one slow round trip starved diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts index 7bf979f8ff0a..f0c3eaa59a83 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts @@ -20,10 +20,8 @@ export function useStructuredAgentSessionTransport(args: { enabled: providerVisible }) // A worktree switch hides the pane, but a message the user already sent is still owed a - // delivery. The read is what carries the journal rows that retire it, and its subscription - // takes a retaining hold — without which the host evicts the session once its idle window runs - // out and then refuses the send outright. Gated on `enabled`: a session not yet published has - // nothing to read. Attention is not the signal; owed work is. + // delivery, and the read is what carries the journal rows that retire it. Gated on `enabled`: + // a session not yet published has nothing to read. Attention is not the signal; owed work is. const hasUndelivered = useUndeliveredStructuredAgentSessionOutbox(sessionId) const read = useStructuredAgentSessionRead({ sessionId, From b36d18876e79a4147af914d5892fa2fa5f54d57c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:05:42 -0700 Subject: [PATCH 083/101] fix(native-chat): a restart offer keeps the start its own continuation made Whose start ended an offer was decided at read time, from whether the offer's continuation was still the queued message. Once the provider refused that continuation, the child it had started read as someone else's start, so the offer ended and its failure showed no Retry. The delivery loop now records which queued message a start is for on the in-memory child, and the child's end carries it; the offer counts a start as its own when that message is one of its continuations. --- .../structured-agent-session-agent-start.ts | 15 +++++-- ...ured-agent-session-attach-orchestration.ts | 8 +++- .../structured-agent-session-delivery-loop.ts | 8 +++- .../structured-agent-session-host-delivery.ts | 6 ++- .../structured-agent-session-host-types.ts | 4 ++ .../structured-agent-session-host.ts | 4 +- ...structured-agent-session-provider-child.ts | 11 +++-- ...t-session-restart-offer-withdrawal.test.ts | 41 +++++++++++++++++++ ...-agent-session-restart-offer-withdrawal.ts | 24 ++++++----- 9 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 73c8bbfff7a2..4a7ac433d206 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -37,12 +37,13 @@ const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' */ export async function ensureStructuredAgentSessionAgent( context: StructuredAgentSessionAttachContext, - sessionId: string + sessionId: string, + startedFor?: string ): Promise { if (context.sessions.get(sessionId)?.child) { return { ok: true } } - const started = await startStructuredAgentSessionAgent(context, sessionId) + const started = await startStructuredAgentSessionAgent(context, sessionId, startedFor) if (!started.ok || context.sessions.get(sessionId)?.child) { return started } @@ -71,7 +72,8 @@ export function ensureStructuredAgentSessionAgentForOperation( async function startStructuredAgentSessionAgent( context: StructuredAgentSessionAttachContext, - sessionId: string + sessionId: string, + startedFor: string | undefined ): Promise { const callerKey = AGENT_START_CALLER_KEY // The record is read only once this host has adjudicated it and exited any recovery stage a @@ -120,7 +122,12 @@ async function startStructuredAgentSessionAgent( } let attached: AgentSessionMutationResult try { - attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params) + attached = await attachStructuredAgentSessionUnderSerialize( + context, + callerKey, + params, + startedFor === undefined ? {} : { startedFor } + ) } catch (error) { // The attach settles an acquisition that failed — the ledger row, the released lease — before // it rethrows the cause. That row is the answer: a failure it recorded is this resume's diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index af59cac448d0..e35a2db0baa7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -44,6 +44,8 @@ import { export type StructuredAgentSessionAttachOptions = { recordPhase?: AgentSessionCreatePhaseRecorder + /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ + startedFor?: string } /** @@ -178,6 +180,7 @@ async function runAttach( onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null + const startedFor = acquiredOwner ? options.startedFor : current?.startedFor // A re-attach to a live child keeps the sink that child already writes through. const eventSink = acquiredOwner ? attemptSink @@ -199,8 +202,9 @@ async function runAttach( child: { generation: acquisitionGeneration ?? current?.generation ?? null, fence, - // A re-attach to a live child keeps what that child already proved. - phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready') + // A re-attach to a live child keeps what that child already proved, and its cause. + phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready'), + ...(startedFor === undefined ? {} : { startedFor }) } } await recoverStructuredRewind( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index f5602022973b..3c3ddbb82efb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -35,7 +35,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ - ensureProviderChild: (sessionId: string) => Promise + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number /** What the chat says when the session could not be made ready. */ @@ -137,7 +141,7 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart) } - const ready = await this.deps.ensureProviderChild(sessionId) + const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) if (!ready.ok) { return ready } 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 07fece8ceac7..46f20b9e67c3 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 @@ -36,7 +36,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise - ensureProviderChild: (sessionId: string) => Promise + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void publishRestored: (sessionId: string) => void }): StructuredAgentSessionConversationDelivery { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 461442717980..af017c0c70d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -39,6 +39,9 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** A publish-first acquire is `starting` until the adapter's `started` event; only then are its * reported options fact. */ phase: StructuredAgentSessionProviderChildPhase + /** The queued message whose delivery started this child, fixed when the start is made; absent + * for any other start. In memory only: it tells a restart offer its own start from another. */ + readonly startedFor?: string } /** What ending a child established about its provider root. A stop's comes only from @@ -62,6 +65,7 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ reason: string | null duringStartup: boolean + startedFor?: string /** Where the conversation's journal stood when the child ended, to order the end against a * message's acceptance. */ endedAt: AgentJournalCursor 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 0f5d306537da..179460abb94e 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 @@ -114,8 +114,8 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. trackStart: (start) => this.tasks.trackAttach(start), - ensureProviderChild: (sessionId) => - ensureStructuredAgentSessionAgent(this.attachContext(), sessionId), + ensureProviderChild: (sessionId, startedFor) => + ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), reset: (sessionId, journal, reset) => this.subscribers.reset( sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 1a792e600b49..aeaae60c86bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -48,13 +48,18 @@ export function markProviderChildStarted( export function endProviderChild( session: ChildBearer, - ended: Omit + ended: Omit ): boolean { - if (!matchingChild(session, ended)) { + const child = matchingChild(session, ended) + if (!child) { return false } session.child = null - session.lastEndedChild = { ...ended, endedAt: session.journal.cursor() } + session.lastEndedChild = { + ...ended, + ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), + endedAt: session.journal.cursor() + } return true } 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 3d78c9f640c6..449a717ed32c 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 @@ -259,6 +259,47 @@ it('keeps a failed resume retryable more than a day later, after the ledger prun expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) }) +// The start was the continuation's own even after the provider refused it: the child it started, +// running or since stopped, is still the offer's, so the offer stays retryable. +it('keeps a resume retryable when its agent started but refused the continuation', async () => { + const state = await offered() + const { host, store } = state + const acquire = state.acquire.getMockImplementation() + if (!acquire) { + throw new Error('the harness acquire has no implementation') + } + state.acquire.mockImplementationOnce(async (input) => ({ + ...(await acquire(input)), + acquisitionGeneration: 'generation-continuation' + })) + state.dispatch.mockResolvedValueOnce({ + state: 'rejected', + reason: 'the provider refused the turn' + }) + + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(state.acquire).toHaveBeenCalledOnce() + expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + // The child the continuation started exits after proving its start. + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: 'generation-continuation', + reason: 'codex app-server exited', + cause: 'unexpected-exit' + }) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + // The rejected continuation is the chat's newest user message; the row still names the user's. it("names the user's prompt on a failed retry, not the rejected continuation", async () => { const state = await offered('submission') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 3288a45308b7..241a96255e99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -31,12 +31,6 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { /** The continuation each running resume action sends, by chat. */ const actions = new Map() - /** The action's own start is the one its continuation is still waiting on. */ - const ownStart = (session: StructuredAgentSessionRestartOfferSession, own: string | undefined) => - own !== undefined && - session.journal.submissions().find((submission) => submission.dispatchState === 'pending') - ?.clientMessageId === own - const movedOn = (marker: AgentSessionResumeMarker): boolean => { const session = deps.sessions.get(marker.sessionId) if (!session) { @@ -59,11 +53,18 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { marker.continuations?.includes(submission.clientMessageId) ) )) - // Proven, not merely spawned: a start that failed during startup never ran the agent. + // Proven, not merely spawned: a start that failed during startup never ran the agent. Whose + // start it was is fixed when it was made, so a continuation rejected since still owns it. const started = - session.child?.phase === 'ready' || - (session.lastEndedChild !== undefined && !session.lastEndedChild.duringStartup) - return accepted || (started && !ownStart(session, own)) + session.child?.phase === 'ready' + ? session.child + : session.lastEndedChild?.duringStartup === false + ? session.lastEndedChild + : undefined + const ownStart = + started?.startedFor !== undefined && + (started.startedFor === own || marker.continuations?.includes(started.startedFor) === true) + return accepted || (started !== undefined && !ownStart) } return { @@ -82,7 +83,8 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { onAgentStarted: (sessionId: string): void => { const session = deps.sessions.get(sessionId) const capsule = deps.capsule - if (!capsule || !session || ownStart(session, actions.get(sessionId))) { + const own = actions.get(sessionId) + if (!capsule || !session || (own !== undefined && session.child?.startedFor === own)) { return } void deps From ca3e68f3d5cf31b935810d18ad2954798bf7cc9e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:11:46 -0700 Subject: [PATCH 084/101] fix(native-chat): an agent gets a full idle window after its owed work ends The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can read done before the lead's wake-up turn writes anything, and stopping in that gap loses the wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full window afterwards, as the release clock it replaced did. --- ...d-agent-session-conversation-close.test.ts | 3 ++- ...tructured-agent-session-idle-sweep.test.ts | 25 ++++++++++++++++++- .../structured-agent-session-idle-sweep.ts | 4 +++ .../runtime/structured-worker-at-rest.test.ts | 1 + 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 9c27dfd7e438..48c68bd5977a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -309,7 +309,8 @@ describe('the wind-down retry with a message queued (P2-31)', () => { const sweep = new StructuredAgentSessionIdleSweep({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. sessions: Object.assign(new Map([[SESSION, session as never]]), { - lastActivityAt: () => 0 + lastActivityAt: () => 0, + touch: () => undefined }), serialize: (_id, task) => task(), now: () => IDLE_MS + 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index b8a4e0390970..5b9582da4bde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -120,6 +120,28 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.adapter.backgroundTaskState.mockReturnValue(undefined) + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + // A finished child reads done before the lead's wake-up turn writes its first row; stopping the + // agent in that gap would lose the wake-up. Owed work is activity, as main's release clock had it. + it('gives an agent a full idle window after its background work ends', async () => { + await foundRestTestChat(rig) + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [{ id: 'subagent-1', kind: 'agent', state: 'working' }] + }) + rig.clock.now += IDLE_MS + 1 + await sweepTicks() + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [{ id: 'subagent-1', kind: 'agent', state: 'done' }] + }) + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + 1 await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) @@ -189,6 +211,7 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) open = false + rig.clock.now += IDLE_MS + 1 await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) @@ -243,7 +266,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { const sessions = Object.assign( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. new Map([[SESSION, { journal, child: null } as never]]), - { lastActivityAt: () => 0 } + { lastActivityAt: () => 0, touch: () => undefined } ) const stopAgent = vi.fn(async () => undefined) const closeConversation = vi.fn(async () => true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 8ed5767c9cd3..3de0d66871e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -21,6 +21,7 @@ export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 export type StructuredAgentSessionIdleSweepDeps = { sessions: ReadonlyMap & { lastActivityAt: (sessionId: string) => number | undefined + touch: (sessionId: string) => void } serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -118,6 +119,9 @@ export class StructuredAgentSessionIdleSweep { return } if (this.owesWork(sessionId, session)) { + // Owed work is activity, so the agent gets a full window once it ends: a child can read + // done before the lead's wake-up turn writes anything. + this.deps.sessions.touch(sessionId) return } await this.deps.stopAgent(sessionId) diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index c87871233b3b..0edbe2cc2cbf 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -317,6 +317,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => { expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('live') db.completeDispatch(handedOn.id) + rig.clock.now += IDLE_MS + 1 await vi.waitFor(() => expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') ) From 194fc33ec2ad60d0400f7041a8ba3f21e46a3184 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:13:06 -0700 Subject: [PATCH 085/101] test(claude): the options-read fixture runs a live child The fixture marked its conversation running with a hasProviderChild field the session type does not have, so the read took the at-rest path and refused a session with no record. It now carries a child, which is what the read checks. --- src/main/claude/claude-context-usage-unloaded-turn.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 3e433d0f126c..b562c839efdb 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -83,7 +83,11 @@ function readOptions( journal: AgentSessionJournal, adapter: Partial ) { - const running = { journal, fence: 1, hasProviderChild: true, params: { provider: 'claude' } } + const running = { + journal, + child: { fence: 1, generation: 'generation-1' }, + params: { provider: 'claude' } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the options read touches only these members. const context = { deps: { From 86d73652ed2fa496facf2bcd92ccaa6002a01a6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:13:31 -0700 Subject: [PATCH 086/101] test(native-chat): host tests reach its collaborators through a typed seam The rest-test rig and three test files read the host's private members with Reflect.get and cast the result. The host now exposes one test-only accessor, collaboratorsForTests(), and the subscribers class a subscriberCountForTests() beside its existing retainedActivityCountForTests(), so the tests are checked against the real types and the casts are gone. --- ...d-agent-session-conversation-close.test.ts | 8 +--- .../structured-agent-session-host.ts | 10 +++++ .../structured-agent-session-rest-test-rig.ts | 13 +----- .../structured-agent-session-subscribers.ts | 4 ++ .../structured-agent-session-at-rest.test.ts | 42 +++++-------------- ...ructured-agent-session-unsubscribe.test.ts | 9 +--- 6 files changed, 29 insertions(+), 57 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 48c68bd5977a..74f916d57c1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -42,8 +42,7 @@ function fence(): number { } function openSession(): StructuredAgentSessionHostSession | undefined { - const sessions: unknown = Reflect.get(rig.host, 'sessions') - return sessions instanceof Map ? sessions.get(SESSION) : undefined + return rig.host.collaboratorsForTests().sessions.get(SESSION) } function statusRows(): AgentSessionStatusSummary[] { @@ -212,10 +211,7 @@ describe('the sweep and the lease (P2-20)', () => { lease: { ...current.lease, handoffStage: 'recovering' } })) const resolveRecovery = vi.spyOn( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private runtime state, spied to prove the sweep never resolves a lease. - Reflect.get(rig.host, 'runtimeState') as { - resolveRecovery: (id: string) => Promise - }, + rig.host.collaboratorsForTests().runtimeState, 'resolveRecovery' ) // Open, at rest, on a lease still recovering. 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 179460abb94e..a24b1d663584 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 @@ -329,4 +329,14 @@ export class StructuredAgentSessionHost { /** Turns that settle from now on. Live-only: nothing missed is replayed. */ subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions + + /** Test rigs only: the collaborators the host builds itself, typed, for tests that drive them. */ + collaboratorsForTests = () => ({ + sessions: this.sessions, + subscribers: this.subscribers, + runtimeState: this.runtimeState, + conversationDelivery: this.conversationDelivery, + lifetime: this.lifetime, + serialize: this.serialize + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 54c28b4b0be9..3ea116fa361d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -29,10 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import { - STRUCTURED_AGENT_SESSION_IDLE_MS, - StructuredAgentSessionIdleSweep -} from './structured-agent-session-idle-sweep' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -216,13 +213,7 @@ export async function foundRestTestChat(rig: RestTestRig): Promise { /** Runs one sweep pass now, for a test that set `idleSweep.intervalMs` out of reach. */ export function sweepOnce(host: StructuredAgentSessionHost): Promise { - const lifetime: unknown = Reflect.get(host, 'lifetime') - const sweep: unknown = - typeof lifetime === 'object' && lifetime !== null ? Reflect.get(lifetime, 'idleSweep') : null - if (!(sweep instanceof StructuredAgentSessionIdleSweep)) { - throw new Error('the host has no idle sweep') - } - return sweep.tick() + return host.collaboratorsForTests().lifetime.idleSweep.tick() } /** Waits long enough for several sweep ticks to have run. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index 0b54ea7c4309..f600d6dd9bba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -58,6 +58,10 @@ export class AgentSessionSubscribers { return this.activityBySession.size } + subscriberCountForTests(sessionId: string): number { + return this.bySession.get(sessionId)?.size ?? 0 + } + open(input: { id: string sessionId: string diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 9730f08f1633..c2633ffe3985 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -161,11 +161,7 @@ describe('the accessor', () => { await rig.restart() setStructuredAgentSessionHost(rig.host) rig.adapter.acquire.mockClear() - const open = vi.spyOn( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private open, spied to count journal opens. - Reflect.get(rig.host, 'conversationDelivery') as { open: (id: string) => Promise }, - 'open' - ) + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') await Promise.all([ ...Array.from({ length: 5 }, () => call('agentSession.history', { sessionId: SESSION, direction: 'tail' }) @@ -181,11 +177,8 @@ describe('the accessor', () => { it('opens nothing once quit began, for a read that was already waiting on the lock', async () => { await restingChat() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private per-session queue, held so the read waits behind it. - const serialize = Reflect.get(rig.host, 'serialize') as ( - sessionId: string, - task: () => Promise - ) => Promise + // The host's per-session queue, held so the read waits behind it. + const { serialize, lifetime } = rig.host.collaboratorsForTests() let release = (): void => undefined const held = new Promise((started) => { void serialize(SESSION, () => { @@ -196,8 +189,8 @@ describe('the accessor', () => { await held const read = rig.host.history({ sessionId: SESSION, direction: 'tail' }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private lifetime, disposed as quit's first teardown step does. - ;(Reflect.get(rig.host, 'lifetime') as { dispose: () => void }).dispose() + // Disposed as quit's first teardown step does. + lifetime.dispose() release() await expect(read).rejects.toThrow() @@ -290,19 +283,14 @@ describe('options at rest', () => { describe('an agent exit', () => { it('is shown, not respawned; the next send starts the agent (P2-18)', async () => { await foundRestTestChat(rig) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private map, read for the child's fence and acquisition. - const sessions = Reflect.get(rig.host, 'sessions') as Map< - string, - { child: { fence: number; generation: string } | null } - > - const running = sessions.get(SESSION)!.child! + const running = rig.host.collaboratorsForTests().sessions.get(SESSION)!.child! await rig.host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, reason: 'killed', cause: 'unexpected-exit', fence: running.fence, - acquisitionGeneration: running.generation + acquisitionGeneration: running.generation! }) await new Promise((resolve) => setTimeout(resolve, 50)) expect(rig.adapter.acquire).toHaveBeenCalledOnce() @@ -315,15 +303,7 @@ describe('an agent exit', () => { it('whose settlement write failed is settled by the next send, which is delivered', async () => { await foundRestTestChat(rig) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private map, read for the child and its journal. - const sessions = Reflect.get(rig.host, 'sessions') as Map< - string, - { - child: { fence: number; generation: string } | null - journal: { appendLifecycleBatch: (...args: never[]) => Promise } - } - > - const open = sessions.get(SESSION)! + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! const running = open.child! // A turn in flight, so the exit has something to settle. rig.adapter.acquire.mock.calls @@ -340,7 +320,7 @@ describe('an agent exit', () => { reason: 'killed', cause: 'unexpected-exit', fence: running.fence, - acquisitionGeneration: running.generation + acquisitionGeneration: running.generation! }) await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.settlementRetryRequired).toBe(true) @@ -375,9 +355,7 @@ describe('every close withdraws what is queued (P2-29)', () => { ])('%s rejects a message accepted before any start, and starts nothing', async (_, close) => { await restingChat() // The delivery loop has not reached its first start yet. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's private delivery loop, held before its first start. - const loop = (Reflect.get(rig.host, 'conversationDelivery') as { loop: { wake: () => void } }) - .loop + const { loop } = rig.host.collaboratorsForTests().conversationDelivery vi.spyOn(loop, 'wake').mockImplementation(() => undefined) const reader: unknown[] = [] await rig.host.subscribe({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts index b1e2b607d0d4..a31a8bc8d186 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts @@ -28,14 +28,7 @@ let rig: RestTestRig let dispatcher: RpcDispatcher function subscriberCount(): number { - const delivery: unknown = Reflect.get(rig.host, 'clientDelivery') - const subscribers: unknown = - typeof delivery === 'object' && delivery !== null ? Reflect.get(delivery, 'subscribers') : null - const bySession: unknown = - typeof subscribers === 'object' && subscribers !== null - ? Reflect.get(subscribers, 'bySession') - : null - return bySession instanceof Map ? (bySession.get(SESSION)?.size ?? 0) : -1 + return rig.host.collaboratorsForTests().subscribers.subscriberCountForTests(SESSION) } async function stream(id: string, frames: RpcResponse[]): Promise { From fb53224ce40c4c0604fc1faf90d4d115b05ee4cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:17:45 -0700 Subject: [PATCH 087/101] refactor(orchestration): one owner answers a structured worker's custody Routing, group addressing, worker-show and the idle sweep each composed their own reading of whether orchestration still holds a structured worker, so each new obligation or retirement state had to be added to every reader. structured-worker-custody now derives both answers from the worker-terminal list state coordinators see in worker-list: addressable is owned and not released, and owed work is an active custody or an unsettled task dispatched to the same incarnation. The owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests. --- .../runtime/orca-runtime-get-worktree-ps.ts | 4 +- .../worker-terminal-transfer.ts | 33 ----- .../structured-worker-group-addressing.ts | 12 +- ...chestration-structured-worker-lifecycle.ts | 2 +- .../worker/worker-observation.ts | 18 +-- .../runtime/structured-worker-at-rest.test.ts | 44 ++++-- .../runtime/structured-worker-authority.ts | 50 +------ src/main/runtime/structured-worker-custody.ts | 138 ++++++++++++++++++ .../runtime/structured-worker-identity.ts | 18 --- 9 files changed, 189 insertions(+), 130 deletions(-) create mode 100644 src/main/runtime/structured-worker-custody.ts diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index d72c250b0976..107cbd63407f 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -19,7 +19,7 @@ import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' -import { structuredWorkerHasOpenDispatch } from './structured-worker-identity' +import { structuredWorkerOwesWork } from './structured-worker-custody' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' @@ -176,7 +176,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps // its agent running. No database answers no. hasOpenDispatch: (record) => - structuredWorkerHasOpenDispatch(this.getOrchestrationDbIfAvailable?.() ?? null, record) + structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record) }) } } diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts index e478f3d736c6..064ae577e0f1 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts @@ -81,37 +81,6 @@ export function workerTerminalResourceHasIdentityConflict( ) } -/** Whether a dispatch that has not settled still addresses this process: the worker-start - * dispatch that owns its terminal, read through the resource row so a transferred terminal - * answers for its current owner, or any task later dispatched to the same incarnation. */ -export function hasOpenWorkerDispatchForProcess( - this: OrchestrationDb, - params: { processIncarnation: string; hostScope: string } -): boolean { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SELECT names exactly this one column. - const owners = this.db - .prepare( - `SELECT owner_dispatch_id FROM worker_terminal_resources - WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned'` - ) - .all(params.processIncarnation, params.hostScope) as { owner_dispatch_id: string }[] - return ( - owners.some((row) => - ['starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown'].includes( - this.getWorkerDispatch(row.owner_dispatch_id)?.state ?? '' - ) - ) || - // Only for a process this host owns a worker terminal for; the resource row carries the scope. - (owners.length > 0 && - this.db - .prepare( - `SELECT 1 FROM dispatch_contexts - WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` - ) - .get(params.processIncarnation) !== undefined) - ) -} - /** Every resource whose process incarnation starts with `prefix`, newest first. */ export function listWorkerTerminalResourcesByIncarnationPrefix( this: OrchestrationDb, @@ -129,7 +98,6 @@ export function listWorkerTerminalResourcesByIncarnationPrefix( export type WorkerTerminalTransferMethods = { findTransferableWorkerTerminalResource: typeof findTransferableWorkerTerminalResource workerTerminalResourceHasIdentityConflict: typeof workerTerminalResourceHasIdentityConflict - hasOpenWorkerDispatchForProcess: typeof hasOpenWorkerDispatchForProcess listWorkerTerminalResourcesByIncarnationPrefix: typeof listWorkerTerminalResourcesByIncarnationPrefix } @@ -137,7 +105,6 @@ export function attachWorkerTerminalTransfer(ctor: { prototype: object }): void Object.assign(ctor.prototype, { findTransferableWorkerTerminalResource, workerTerminalResourceHasIdentityConflict, - hasOpenWorkerDispatchForProcess, listWorkerTerminalResourcesByIncarnationPrefix }) } diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index dd237a7e4991..4115f168552b 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -16,11 +16,8 @@ */ import type { TuiAgent } from '../../../shared/tui-agent' -import { - structuredWorkerAgent, - structuredWorkerOwned, - structuredWorkerResourceReleased -} from '../structured-worker-authority' +import { structuredWorkerAgent } from '../structured-worker-authority' +import { structuredWorkerAddressable } from '../structured-worker-custody' import { STRUCTURED_WORKER_INCARNATION_PREFIX, structuredWorkerIdentityFromRow @@ -58,10 +55,7 @@ export function listAddressableStructuredWorkers( return [] } seen.add(identity.sessionId) - return structuredWorkerOwned(identity.sessionId) === true && - !structuredWorkerResourceReleased(row) - ? [identity] - : [] + return structuredWorkerAddressable(db, identity.sessionId, row) === true ? [identity] : [] }) .map((identity) => ({ handle: identity.handle, diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index c6bb3d53d583..bec15537c49d 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -37,10 +37,10 @@ import { observeStructuredWorker, resolveStructuredWorkerIdentity, structuredWorkerAgent, - structuredWorkerOwned, structuredWorkerTerminalState, type StructuredWorkerObservation } from '../../structured-worker-authority' +import { structuredWorkerOwned } from '../../structured-worker-custody' import type { StructuredWorkerIdentity } from '../../structured-worker-identity' import type { WorkerTerminalReleaseState } from '../../orchestration/worker-terminal-ownership' import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index e57c5882f785..81f3a2c2975a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -7,10 +7,9 @@ import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestr import { projectWorkerFleet } from './worker-list-projection' import { observeStructuredWorker, - resolveStructuredWorkerForDispatch, - structuredWorkerOwned + resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' -import { structuredWorkerResourceReleased } from '../../../../structured-worker-authority' +import { structuredWorkerAddressable } from '../../../../structured-worker-custody' import type { DispatchContextRow, FederatedDispatchRow, @@ -54,14 +53,11 @@ export async function inspectWorkerTerminal( processIncarnation: structured.processIncarnation }) const observation = observeStructuredWorker(structured) - const owned = structuredWorkerOwned(structured.sessionId) - const addressable = - owned === null - ? null - : owned && - !structuredWorkerResourceReleased( - db.getWorkerTerminalResourceByHandle?.(structured.handle) - ) + const addressable = structuredWorkerAddressable( + db, + structured.sessionId, + db.getWorkerTerminalResourceByHandle?.(structured.handle) + ) return { terminal: null, exact, diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index 0edbe2cc2cbf..28317e4da884 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -12,16 +12,13 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( getStructuredAgentSessionHost: () => hostRef.current })) -const { - observeStructuredWorker, - resolveStructuredWorkerAuthority, - structuredSessionCloseSettled, - structuredWorkerOwned -} = await import('./structured-worker-authority') +const { observeStructuredWorker, resolveStructuredWorkerAuthority, structuredSessionCloseSettled } = + await import('./structured-worker-authority') +const { structuredWorkerOwesWork, structuredWorkerOwned } = + await import('./structured-worker-custody') const { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, - structuredWorkerHasOpenDispatch, structuredWorkerIdentities, structuredWorkerProcessIncarnation } = await import('./structured-worker-identity') @@ -117,13 +114,32 @@ describe('an open dispatch keeps its worker running (P2-19 i)', () => { it('answers open while the dispatch starts, runs or stops, and closed once it settles', () => { const dispatch = dispatchWorker() - expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(true) + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) db.markWorkerDispatchReady(dispatch.id) - expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(true) + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) db.beginWorkerStop(dispatch.id, 'epoch_home') - expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(true) + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) db.settleWorkerStop(dispatch.id) - expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(false) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + }) + + // Custody is the list state coordinators see: `active` owes work, `reclaimable` does not. + it('lets a worker awaiting its coordinator rest, and keeps one whose stop is in doubt', () => { + const done = dispatchWorker() + db.markWorkerDispatchReady(done.id) + db.settleWorkerReport({ + taskId: done.task_id, + dispatchId: done.id, + outcome: 'succeeded', + result: 'done' + }) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + + const doubted = dispatchWorker() + db.markWorkerDispatchReady(doubted.id) + db.beginWorkerStop(doubted.id, 'epoch_home') + db.markWorkerStopUnknown(doubted.id, 'the close was not proven') + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) }) it('keeps a worker at rest after its dispatch settles a group recipient, until its tab goes (P2-19 ii)', () => { @@ -197,8 +213,8 @@ describe('an open dispatch keeps its worker running (P2-19 i)', () => { it('reads only this host scope, and no database answers no', () => { dispatchWorker(JSON.stringify({ kind: 'ssh', targetId: 'elsewhere' })) - expect(structuredWorkerHasOpenDispatch(db, record({}))).toBe(false) - expect(structuredWorkerHasOpenDispatch(null, record({}))).toBe(false) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + expect(structuredWorkerOwesWork(null, record({}))).toBe(false) }) }) @@ -265,7 +281,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => { it('keeps the worker running while that task is open, and lets it rest once the task settles', async () => { const db = new OrchestrationDb(':memory:') const rig = await createRestTestRig({ - hasOpenDispatch: (current) => structuredWorkerHasOpenDispatch(db, current) + hasOpenDispatch: (current) => structuredWorkerOwesWork(db, current) }) try { hostRef.current = rig.host diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index dd1595cea4bc..24fda6b1456e 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -3,7 +3,8 @@ * * The registry holds the handle→session mapping for this process; the durable worker-terminal * resource row is what survives a restart, so a miss falls back to rehydrating from it. The - * durable agent-session record and the chat's tab are the ownership half: see `structuredWorkerOwned`. + * durable agent-session record, the chat's tab and the orchestration's own resource row decide + * custody: see `structured-worker-custody`. * Whether its provider process runs is a separate fact, `observeStructuredWorker`, and routing * never reads it — an agent at rest still receives mail, which starts it. */ @@ -13,12 +14,11 @@ import type { OrcaSessionId } from '../../shared/orca-session-address' import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' -import type { WorkerTerminalResourceRow } from './orchestration/worker-terminal-ownership' +import { structuredWorkerAddressable } from './structured-worker-custody' import { isStructuredWorkerHandle, structuredWorkerIdentities, structuredWorkerProcessIncarnation, - structuredWorkerRecordIsCurrent, type StructuredWorkerIdentity } from './structured-worker-identity' @@ -51,35 +51,6 @@ export function resolveStructuredWorkerIdentity( return row ? structuredWorkerIdentities.rehydrate(row) : null } -/** - * Whether this runtime still owns the worker's session: routing, addressing and authority ask - * this, never whether its process runs. Null when the host is not installed, because reading the - * record store would install it — not being able to look is not an answer. - */ -export function structuredWorkerOwned(sessionId: string): boolean | null { - const host = getStructuredAgentSessionHost() - if (!host) { - return null - } - const record = readStructuredAgentSessionRecord(sessionId) - return structuredWorkerRecordIsCurrent( - record, - record?.lease.claimStatus === 'released' && structuredWorkerTabListed(host, sessionId) - ) -} - -/** Retirement is the tab index: every path that ends a chat for good hides its tab. */ -function structuredWorkerTabListed( - host: NonNullable>, - sessionId: string -): boolean { - try { - return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false - } catch { - return false - } -} - /** The worker identity minted for a session, if that session is a structured worker. */ export function resolveStructuredWorkerIdentityForSession( sessionId: string, @@ -125,20 +96,15 @@ export function resolveStructuredWorkerAuthority( } const record = readStructuredAgentSessionRecord(identity.sessionId) return record && - structuredWorkerOwned(identity.sessionId) && - !structuredWorkerResourceReleased(db?.getWorkerTerminalResourceByHandle?.(identity.handle)) + structuredWorkerAddressable( + db, + identity.sessionId, + db?.getWorkerTerminalResourceByHandle?.(identity.handle) + ) ? { identity, record } : null } -/** The orchestration released this worker: its coordinator is done with it, as with a terminal - * worker whose terminal closed, so nothing routes to it. Its chat stays the user's. */ -export function structuredWorkerResourceReleased( - row: Pick | undefined -): boolean { - return row?.release_state === 'released' || row?.ownership_state === 'released' -} - /** * Which provider this worker actually talks to. * diff --git a/src/main/runtime/structured-worker-custody.ts b/src/main/runtime/structured-worker-custody.ts new file mode 100644 index 000000000000..c14fd958c616 --- /dev/null +++ b/src/main/runtime/structured-worker-custody.ts @@ -0,0 +1,138 @@ +/** + * Whether orchestration still holds a structured worker, the one answer every reader derives from. + * + * Custody is the orchestration's own worker-terminal state, the list state `worker-list` shows + * coordinators, never whether the worker's process runs: a worker at rest is still held, and mail + * starts it. Routing, group addressing and `worker-show` ask whether it is addressable; the idle + * sweep asks whether it still owes work. + */ + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { OrchestrationDb } from './orchestration/db' +import type { WorkerDispatchState } from './orchestration/types' +import { + deriveWorkerTerminalListState, + type WorkerDispatchListState, + type WorkerTerminalListState, + type WorkerTerminalResourceRow +} from './orchestration/worker-terminal-ownership' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + structuredWorkerHostScope, + structuredWorkerProcessIncarnation, + structuredWorkerRecordIsCurrent +} from './structured-worker-identity' + +/** + * Whether this runtime still owns the worker's session: routing, addressing and authority ask + * this, never whether its process runs. Null when the host is not installed, because reading the + * record store would install it — not being able to look is not an answer. + */ +export function structuredWorkerOwned(sessionId: string): boolean | null { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + let record: AgentSessionRecord | null + try { + record = host.deps.store.getRecord(sessionId) + } catch { + record = null + } + return structuredWorkerRecordIsCurrent( + record, + record?.lease.claimStatus === 'released' && structuredWorkerTabListed(host, sessionId) + ) +} + +/** Retirement is the tab index: every path that ends a chat for good hides its tab. */ +function structuredWorkerTabListed( + host: NonNullable>, + sessionId: string +): boolean { + try { + return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + } catch { + return false + } +} + +type CustodyRow = Pick< + WorkerTerminalResourceRow, + 'owner_dispatch_id' | 'terminal_handle' | 'ownership_state' | 'release_state' +> + +function ownerState( + db: OrchestrationDb | null | undefined, + row: CustodyRow +): WorkerDispatchState | undefined { + return ( + db?.getWorkerDispatch?.(row.owner_dispatch_id)?.state ?? + db?.getRemoteDispatchAttachment?.(row.owner_dispatch_id)?.state + ) +} + +function custodyState( + row: CustodyRow, + workerState: WorkerDispatchListState +): WorkerTerminalListState | null { + return deriveWorkerTerminalListState({ + workerState, + agentTerminalHandle: row.terminal_handle, + resource: row + }) +} + +/** + * The user still owns the chat and orchestration has not released the worker, as with a terminal + * worker whose terminal closed. Null when ownership cannot be read. A released worker's chat stays + * the user's; nothing routes to it. + */ +export function structuredWorkerAddressable( + db: OrchestrationDb | null | undefined, + sessionId: string, + row: CustodyRow | undefined +): boolean | null { + const owned = structuredWorkerOwned(sessionId) + // Release is read off the row alone, so an owner whose state is unreadable still answers. + return owned === null + ? null + : owned && (!row || custodyState(row, ownerState(db, row) ?? 'unsupervised') !== 'released') +} + +/** + * Work orchestration still owes on this worker, read per sweep tick: a resource whose custody is + * `active` (its worker-start dispatch has not settled, a stop in doubt included), or any unsettled + * task later dispatched to the same incarnation on a process this host owns a terminal for. + * A settled worker awaiting its coordinator's decision (`reclaimable`) may rest. + */ +export function structuredWorkerOwesWork( + db: OrchestrationDb | null, + record: AgentSessionRecord +): boolean { + const hostScope = structuredWorkerHostScope(record.location) + if (!db || !hostScope) { + return false + } + const incarnation = structuredWorkerProcessIncarnation(record.sessionId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table is exactly its row shape. + const owned = db.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned'` + ) + .all(incarnation, JSON.stringify(hostScope)) as WorkerTerminalResourceRow[] + return ( + owned.some((row) => { + const workerState = ownerState(db, row) + return workerState !== undefined && custodyState(row, workerState) === 'active' + }) || + (owned.length > 0 && + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .get(incarnation) !== undefined) + ) +} diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 717ad06569ed..e09adee7c1e2 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -30,7 +30,6 @@ import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' -import type { OrchestrationDb } from './orchestration/db' // Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the // renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. @@ -160,23 +159,6 @@ export function structuredWorkerRecordIsCurrent( ) } -/** Whether a dispatch that has not settled still addresses this session's worker, keyed the way - * the worker's terminal resource row is: its process incarnation and host scope. */ -export function structuredWorkerHasOpenDispatch( - db: OrchestrationDb | null, - record: AgentSessionRecord -): boolean { - const hostScope = structuredWorkerHostScope(record.location) - return Boolean( - db && - hostScope && - db.hasOpenWorkerDispatchForProcess({ - processIncarnation: structuredWorkerProcessIncarnation(record.sessionId), - hostScope: JSON.stringify(hostScope) - }) - ) -} - type StructuredWorkerResourceRow = { terminal_handle: string pane_key: string | null From 0b975be1f6290390a85e1400f8597ccfd1a90c88 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:19:34 -0700 Subject: [PATCH 088/101] test(native-chat): the owner-status restart case runs the startup pass the PR replaced the restorer with --- .../structured-chat-owner-status-activation.unit.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 f2c7cbd39d45..1d541819e4b3 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -136,12 +136,13 @@ 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 settled it without keeping it open', async () => { + await host.setSessionTabVisibility(SESSION, true) await host.flushAllStreamedEvents() store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost() - await host.restoreReadableSessions() - expect(host.hasSession(SESSION)).toBe(true) + await host.restoreStartupSessions() + expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null From 1eea2d8712e192d16fe33a4e3a7ce7620d778d35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:19:36 -0700 Subject: [PATCH 089/101] fix(native-chat): keep a legacy store's seeded tab list when another writer rewrites the file before the first write --- ...ent-session-record-store-tab-table.test.ts | 44 +++++++++++++++++++ .../runtime/agent-session-record-store.ts | 8 +++- .../agent-session-store-transaction-queue.ts | 13 ++++-- src/main/runtime/agent-session-tab-table.ts | 5 ++- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 227bb7a88f73..1af97b7de172 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -215,6 +215,50 @@ describe('chat tab table', () => { expect(restarted.listVisibleSessionIds()).toEqual(['session-alpha', 'session-gamma']) }) + it('keeps the seed when another writer rewrites the legacy file before the first write', async () => { + const first = await open() + for (const [index, sessionId] of ['session-alpha', 'session-beta', 'session-gamma'].entries()) { + await first.reserveOwner( + reserveRequest({ + sessionId, + operation: { + callerKey: 'client-1', + operationId: operationId(), + fingerprint: `fp-${index}` + } + }) + ) + } + const raw = await readFileJson() + delete raw.sessionTabs + delete raw.visibleSessionIds + await writeFile(filePath(), JSON.stringify(raw)) + const seeded = await AgentSessionRecordStore.open({ + directory, + hostId: 'local', + savedTabSessionIds: () => ['session-alpha', 'session-beta', 'session-gamma'] + }) + // An outgoing build, which knows no tab index, still writes the file mid-restart. + raw.retiredClaimKeys = [ + ...(raw.retiredClaimKeys ?? []), + { keyId: 'key-outgoing', retiredAt: NOW } + ] + await writeFile(filePath(), JSON.stringify(raw)) + + await seeded.setSessionTabVisibility('session-beta', true) + + expect(seeded.listVisibleSessionIds()).toEqual([ + 'session-alpha', + 'session-beta', + 'session-gamma' + ]) + expect((await readFileJson()).visibleSessionIds).toEqual([ + 'session-alpha', + 'session-beta', + 'session-gamma' + ]) + }) + it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { const first = await open() for (const [index, sessionId] of ['session-alpha', 'clear-one', 'clear-two'].entries()) { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 868f83f4a82c..8e07b86ebfab 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -99,13 +99,17 @@ export class AgentSessionRecordStore { // file mid-restart. markAgentSessionStoreLeasesUnreconciled(loaded.state) // After the revision for the same reason: the seeded table reaches disk with the first write. - const seeded = seedAgentSessionTabTable(loaded.state, args.savedTabSessionIds?.() ?? []) + // Re-seeded on reload too, or an outgoing build's write before that one would drop it. + const seed = (state: AgentSessionStoreState) => + seedAgentSessionTabTable(state, args.savedTabSessionIds?.() ?? []) + const seeded = seed(loaded.state) const needsRewrite = loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized || seeded const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( filePath, args.hostId, { ...loaded, needsRewrite }, - diskRevision + diskRevision, + seed ) if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { await transactions.persistLoadedRewrite() diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts index 38b65ef62498..bff4bbbb8ee7 100644 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -64,7 +64,9 @@ export class AgentSessionStoreTransactionQueue { private diskStoreFound: boolean, public state: AgentSessionStoreState, private diskRevision: string, - private needsRewrite: boolean + private needsRewrite: boolean, + /** Re-applied to every reload of another writer's file; true when it changed the state. */ + private readonly normalizeLoaded: (state: AgentSessionStoreState) => boolean = () => false ) { this.diskRecoveredFromBackup = recoveredFromBackup } @@ -73,7 +75,8 @@ export class AgentSessionStoreTransactionQueue { filePath: string, hostId: string, loaded: LoadedAgentSessionStore, - diskRevision: string + diskRevision: string, + normalizeLoaded?: (state: AgentSessionStoreState) => boolean ): AgentSessionStoreTransactionQueue { return new AgentSessionStoreTransactionQueue( filePath, @@ -83,7 +86,8 @@ export class AgentSessionStoreTransactionQueue { loaded.storeFound, loaded.state, diskRevision, - loaded.needsRewrite + loaded.needsRewrite, + normalizeLoaded ) } @@ -165,9 +169,10 @@ export class AgentSessionStoreTransactionQueue { throw new Error('agent_session_legacy_required') } markLoadedLeasesUnreconciled(loaded.state) + const normalized = this.normalizeLoaded(loaded.state) this.state = loaded.state this.diskRevision = diskRevision - this.needsRewrite = loaded.needsRewrite + this.needsRewrite = loaded.needsRewrite || normalized } } diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index e1fa848ec864..90bf876c8ef4 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -132,8 +132,9 @@ export function setAgentSessionTabVisibility( /** * A store written before any tab index lists its chats only in the saved workspace session. The - * table is seeded from that session once, at open, so no later single write can create a table - * holding one chat and drop the rest. True when it seeded. + * table is seeded from that session at open, and again on any reload of a file still without one, + * so no later single write can create a table holding one chat and drop the rest. True when it + * seeded. */ export function seedAgentSessionTabTable( state: AgentSessionStoreState, From ef47c7d5d75c92e7aa71e5a319a312fe1a82013a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:21:06 -0700 Subject: [PATCH 090/101] fix(native-chat): a saved-status flush that finds the file corrupt recreates it instead of failing every flush --- .../agent-session-saved-status-store.test.ts | 29 +++++++++++++++++++ .../agent-session-saved-status-store.ts | 5 +++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/agent-session-saved-status-store.test.ts b/src/main/runtime/agent-session-saved-status-store.test.ts index dc839c70deb9..a6e03a019268 100644 --- a/src/main/runtime/agent-session-saved-status-store.test.ts +++ b/src/main/runtime/agent-session-saved-status-store.test.ts @@ -124,6 +124,35 @@ describe('saved chat status store', () => { expect(open(dir).read('s1')).toEqual(saved(3)) }) + it('recreates the file when a flush finds it corrupt after SQLite already rolled back', () => { + const dir = tempDir() + const store = open(dir) + store.record('before', saved(1)) + store.flush() + const exec = Database.prototype.exec + let failCommit = true + vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database.Database, + sql: string + ) { + if (sql === 'COMMIT' && failCommit) { + failCommit = false + // SQLite ends the transaction itself on a corrupt page, before the error reaches us. + exec.call(this, 'ROLLBACK') + throw Object.assign(new Error('database disk image is malformed'), { + code: 'SQLITE_CORRUPT' + }) + } + return exec.call(this, sql) + }) + store.record('after', saved(2)) + expect(store.flush()).toBe(false) + store.close() + const reopened = open(dir) + expect(reopened.read('before')).toBeNull() + expect(reopened.read('after')).toEqual(saved(2)) + }) + it('leaves a newer build’s file untouched and reads or writes nothing from it', () => { const dir = tempDir() const path = join(dir, AGENT_SESSION_SAVED_STATUS_FILE) diff --git a/src/main/runtime/agent-session-saved-status-store.ts b/src/main/runtime/agent-session-saved-status-store.ts index f4fa1586cc27..a26089a3052a 100644 --- a/src/main/runtime/agent-session-saved-status-store.ts +++ b/src/main/runtime/agent-session-saved-status-store.ts @@ -187,7 +187,10 @@ export class AgentSessionSavedStatusStore { } db.exec('COMMIT') } catch (error) { - db.exec('ROLLBACK') + // SQLite may have rolled back already; a second ROLLBACK would mask a corrupt file. + if (db.isTransaction) { + db.exec('ROLLBACK') + } throw error } } catch (error) { From eb9c79be5a91e2419840e7980ad634a265d6f985 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:36:19 -0700 Subject: [PATCH 091/101] fix(native-chat): a failed startup reconcile still settles every listed chat, and the pass closes what it opened by the idle sweep's rule The first clean reconcile after a failed startup one, from a bounded retry or from any attach, reruns the startup pass's post-reconcile half once, so a crashed chat nobody opened leaves "working". The per-step reader flag is gone: the pass closes a conversation it opened when the idle sweep would. --- ...red-agent-session-conversation-lifetime.ts | 33 +--- .../structured-agent-session-host-types.ts | 2 + .../structured-agent-session-host.ts | 3 +- ...tructured-agent-session-restart-restore.ts | 150 +++++++++++++----- ...nt-session-startup-reconcile-retry.test.ts | 122 ++++++++++++++ ...d-agent-session-startup-settlement.test.ts | 33 ++-- 6 files changed, 256 insertions(+), 87 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 10710a1935b2..73d1825bee5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -36,8 +36,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { closeStatus: (sessionId: string, options: { listed: boolean }) => void }) { let disposed = false - // Sessions inside a startup step, and whether any `conversation()` caller reached one meanwhile. - const reachedDuringStartupStep = new Map() const { sessions, serialize } = host const deps = () => host.context().deps // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. @@ -93,9 +91,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { * drop it after. */ conversation: async (sessionId: string): Promise => { - if (reachedDuringStartupStep.has(sessionId)) { - reachedDuringStartupStep.set(sessionId, true) - } const open = sessions.get(sessionId) if (open) { return open @@ -124,29 +119,15 @@ export function createStructuredAgentSessionConversationLifetime(host: { return session }) }, + isDisposed: (): boolean => disposed, /** The startup pass's per-chat step, under the session's lock and never after quit began. A - * conversation the step opens only to derive status is closed again unless a reader reached - * it meanwhile or it has a child. */ + * conversation the step opens only to derive status closes again as the idle sweep's would. */ startupStep: ((sessionId, step) => - serialize(sessionId, async () => { - if (disposed) { - return undefined - } - reachedDuringStartupStep.set(sessionId, false) - try { - return await step({ - session: sessions.get(sessionId), - closeIfUnreached: async () => { - const session = sessions.get(sessionId) - if (session && !session.child && !reachedDuringStartupStep.get(sessionId)) { - await closeConversation(sessionId) - } - } - }) - } finally { - reachedDuringStartupStep.delete(sessionId) - } - })) satisfies StructuredAgentSessionStartupStep, + serialize(sessionId, async () => + disposed + ? undefined + : step({ session: sessions.get(sessionId), close: () => closeConversation(sessionId) }) + )) satisfies StructuredAgentSessionStartupStep, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent. */ close: (sessionId: string): Promise => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 290137641fe2..f00673e52a55 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -113,6 +113,8 @@ export type StructuredAgentSessionHostDeps = { now?: () => number /** The idle sweep's period and window. Tests drive these; production takes the defaults. */ idleSweep?: { intervalMs?: number; idleMs?: number } + /** Waits before each retry of a startup reconcile that did not complete; tests shorten them. */ + startupReconcileRetryDelaysMs?: readonly number[] /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void 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 472b87f0cea3..8ceb82a28e4d 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 @@ -164,7 +164,8 @@ export class StructuredAgentSessionHost { reconcile: () => this.reconcileLeases('startup'), resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), open: (sessionId) => this.conversationDelivery.open(sessionId), - now: () => this.now() + now: () => this.now(), + disposed: this.lifetime.isDisposed }) this.runtimeState.startLeaseRenewal() this.lifetime.idleSweep.start() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 06266e1ab128..5794dad8c113 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -12,7 +12,8 @@ // a match publishes the row, anything else is remembered as a miss; // 2. after reconcile: recovery exits, then settlement of every open conversation, then each miss // and each listed record still owing settlement is opened (unless already open), settled, and -// closed again if nothing but this pass wanted it open. +// closed again when closable. A startup reconcile that fails leaves that half owed: the first +// clean reconcile after it, a bounded retry's or anyone's, runs it once more. // Every step runs under its own session's lock with a yield between chats, so a read or a send // jumps the queue and waits for one chat at most. @@ -49,15 +50,18 @@ export type StructuredAgentSessionStartupPriority = { } /** One chat's share of the pass, run under its lock. `session` is the conversation open when the - * step began; `closeIfUnreached` drops it again unless a reader reached it during the step. */ + * step began; `close` drops it again by the idle sweep's rule, so one still owing work stays. */ export type StructuredAgentSessionStartupStep = ( sessionId: string, step: (context: { session: StructuredAgentSessionHostSession | undefined - closeIfUnreached: () => Promise + close: () => Promise }) => Promise ) => Promise +/** Waits before each retry of a startup reconcile that did not complete. */ +const STARTUP_RECONCILE_RETRY_DELAYS_MS = [1_000, 4_000, 15_000] + export type StructuredAgentSessionStartupPassDeps = { store: Pick journalRoot: string @@ -76,12 +80,16 @@ export type StructuredAgentSessionStartupPassDeps = { settle: (sessionId: string, journal: AgentSessionJournal) => Promise onError: (sessionId: string, error: unknown) => void now: () => number + /** Quit has begun: no retry starts after this. */ + disposed: () => boolean + reconcileRetryDelaysMs?: readonly number[] | undefined } export type StructuredAgentSessionStartupPass = { /** Latched: every later call is the same pass, retried only after a failure. */ run: (priority?: StructuredAgentSessionStartupPriority) => Promise - /** A later reconcile settled every lease: settle what it flagged on open conversations. */ + /** A later reconcile settled every lease: settle what it flagged on open conversations, or, after + * a failed startup reconcile, run the whole post-reconcile half once. */ onReconciled: () => void } @@ -130,6 +138,11 @@ export function createStructuredAgentSessionStartupPass( ): StructuredAgentSessionStartupPass { let running: Promise | null = null let settledOpenOnce = false + let priority: StructuredAgentSessionStartupPriority | undefined + // A failed startup reconcile left recovery and settlement owed until a clean one lands. + let startupReconcileFailed = false + let cleanReconcileSinceFailure = false + let afterFailure: Promise | null = null const listed = (sessionId: string): boolean => deps.store.listVisibleSessionIds().includes(sessionId) const supportedRecord = (sessionId: string): AgentSessionRecord | null => { @@ -173,9 +186,9 @@ export function createStructuredAgentSessionStartupPass( } } - /** Phase 2.3's step: open unless open, always settle, and close what only this pass wanted. */ + /** Phase 2.3's step: open unless open, always settle, and close what only this pass opened. */ const openAndSettle = async (sessionId: string): Promise => { - await deps.step(sessionId, async ({ session, closeIfUnreached }) => { + await deps.step(sessionId, async ({ session, close }) => { const record = supportedRecord(sessionId) if (!record || !listed(sessionId)) { return @@ -193,12 +206,77 @@ export function createStructuredAgentSessionStartupPass( } await deps.settle(sessionId, open.journal) if (!session) { - await closeIfUnreached() + await close() } }) } - const runOnce = async (priority?: StructuredAgentSessionStartupPriority): Promise => { + /** Everything a restart owes once reconcile has answered; `reconciled` gates recovery exits. */ + const settleAfterReconcile = async ( + reconciled: boolean, + misses: readonly string[] + ): Promise => { + if (reconciled) { + // Every record, hidden ones too: an orphaned owner of a closed tab is still this host's. + for (const record of deps.store.listRecords()) { + if (structuredSessionRecoveryIsResolvable(record)) { + await deps + .resolveRecovery(record.sessionId) + .catch((error: unknown) => deps.onError(record.sessionId, error)) + } + } + } + await settleOpenConversations() + settledOpenOnce = true + const owed = deps.store + .listVisibleSessionIds() + .filter((sessionId) => structuredAgentSessionOwesSettlement(deps.store.getRecord(sessionId))) + const targets = orderStartupSessionIds( + [...new Set([...misses, ...owed])], + (sessionId) => deps.store.getRecord(sessionId)?.location.workspaceId, + priority + ) + for (const sessionId of targets) { + // Logged once, never retried by the pass: the first read reports it. + await openAndSettle(sessionId).catch((error: unknown) => deps.onError(sessionId, error)) + await yieldToEventLoop() + } + } + + /** At most once per failed startup: the misses were already opened, so only what reconcile + * flagged is left. */ + const settleAfterFailedStartup = (): Promise => { + afterFailure ??= settleAfterReconcile(true, []).catch((error: unknown) => + deps.onError('startup-pass', error) + ) + return afterFailure + } + + const reconcileClean = (): Promise => + deps.reconcile().then( + (refusal) => refusal === null, + (error: unknown) => { + deps.onError('startup-pass', error) + return false + } + ) + + /** Retries the startup reconcile a few times, then leaves the rest to each chat's own attach. */ + const retryStartupReconcile = async (): Promise => { + for (const delay of deps.reconcileRetryDelaysMs ?? STARTUP_RECONCILE_RETRY_DELAYS_MS) { + await new Promise((resolve) => setTimeout(resolve, delay).unref?.()) + if (deps.disposed() || afterFailure) { + return + } + if (await reconcileClean()) { + await settleAfterFailedStartup() + return + } + } + deps.onError('startup-pass', new Error('startup reconcile still failing after its retries')) + } + + const runOnce = async (): Promise => { const startedAt = deps.now() const misses: string[] = [] let seeded = 0 @@ -226,51 +304,38 @@ export function createStructuredAgentSessionStartupPass( console.info( `[structured-agent-session] startup status: ${seeded} from saved copies, ${misses.length} to open, ${deps.now() - startedAt} ms` ) - // Settled either way: a failed reconcile still leaves the opens and settlements below owed. - const reconciled = await deps.reconcile().then( - (refusal) => refusal === null, - (error: unknown) => { - deps.onError('startup-pass', error) - return false - } - ) + // Settled either way: a failed reconcile still leaves the misses to open and publish. + const reconciled = await reconcileClean() + startupReconcileFailed = !reconciled + await settleAfterReconcile(reconciled, misses) if (reconciled) { - // Every record, hidden ones too: an orphaned owner of a closed tab is still this host's. - for (const record of deps.store.listRecords()) { - if (structuredSessionRecoveryIsResolvable(record)) { - await deps - .resolveRecovery(record.sessionId) - .catch((error: unknown) => deps.onError(record.sessionId, error)) - } - } + return } - await settleOpenConversations() - settledOpenOnce = true - const owed = deps.store - .listVisibleSessionIds() - .filter((sessionId) => structuredAgentSessionOwesSettlement(deps.store.getRecord(sessionId))) - const targets = orderStartupSessionIds( - [...new Set([...misses, ...owed])], - (sessionId) => deps.store.getRecord(sessionId)?.location.workspaceId, - priority - ) - for (const sessionId of targets) { - // Logged once, never retried by the pass: the first read reports it. - await openAndSettle(sessionId).catch((error: unknown) => deps.onError(sessionId, error)) - await yieldToEventLoop() + if (cleanReconcileSinceFailure) { + await settleAfterFailedStartup() + return } + void retryStartupReconcile() } return { - run: (priority) => { - running ??= runOnce(priority).catch((error: unknown) => { + run: (startupPriority) => { + priority ??= startupPriority + running ??= runOnce().catch((error: unknown) => { running = null throw error }) return running }, onReconciled: () => { - if (settledOpenOnce) { + if (startupReconcileFailed && !afterFailure) { + if (settledOpenOnce) { + void settleAfterFailedStartup() + } else { + // The failed startup's own half is still settling; it runs the rest when it finishes. + cleanReconcileSinceFailure = true + } + } else if (settledOpenOnce) { void settleOpenConversations() } } @@ -282,7 +347,7 @@ export function createStructuredAgentSessionHostStartupPass( deps: StructuredAgentSessionHostDeps, wiring: Pick< StructuredAgentSessionStartupPassDeps, - 'sessions' | 'step' | 'seed' | 'reconcile' | 'resolveRecovery' | 'open' | 'now' + 'sessions' | 'step' | 'seed' | 'reconcile' | 'resolveRecovery' | 'open' | 'now' | 'disposed' > ): StructuredAgentSessionStartupPass { return createStructuredAgentSessionStartupPass({ @@ -290,6 +355,7 @@ export function createStructuredAgentSessionHostStartupPass( store: deps.store, journalRoot: deps.journalRoot, ...(deps.savedStatus ? { savedStatus: deps.savedStatus } : {}), + reconcileRetryDelaysMs: deps.startupReconcileRetryDelaysMs, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), settle: (sessionId, journal) => retryLoadedStructuredAgentSessionSettlement({ deps, sessionId, journal, now: wiring.now }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts new file mode 100644 index 000000000000..79b6f9247a6d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts @@ -0,0 +1,122 @@ +// A startup reconcile that fails leaves a crashed chat's turn unsettled: only reconcile can say its +// agent is gone. The first clean reconcile after it, from a bounded retry or from anyone, must +// settle every listed chat that owes it, not only the ones some reader happened to open. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + createStartupRig, + isOpen, + latestStatus, + type StartupRig +} from './structured-agent-session-startup-pass-test-rig' + +let rig: StartupRig + +beforeEach(async () => { + rig = await createStartupRig() +}) + +afterEach(async () => { + await rig.dispose() +}) + +const owesSettlement = (sessionId: string) => + rig.store.getRecord(sessionId)?.lease.settlementRetryRequired === true + +/** A second run's send, then a crash with the provider's turn still running. */ +async function crashMidTurn(sessionId: string): Promise { + await rig.chat(sessionId, { message: 'first' }) + await rig.quit() + const host = await rig.boot() + await host.restoreStartupSessions() + await rig.chat(sessionId, { message: 'second' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's session map, reached only to journal the provider's running turn as its event path would. + const sessions = ( + rig.host as unknown as { sessions: Map } + ).sessions + await sessions + .get(sessionId)! + .journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 'turn-running', ordinal: 99 }, + { kind: 'turn', turnId: 'turn-running', state: 'running', startedAt: 1 }, + { fence: rig.store.getRecord(sessionId)!.lease.runtimeFence } + ) + await rig.crash() +} + +/** Boots with every owner probe failing until `probe.fail` is cleared. */ +async function bootWithFailingReconcile(deps: Partial = {}) { + const probe = { fail: true } + rig.probeOwner.mockImplementation(async () => { + if (probe.fail) { + throw new Error('probe unavailable') + } + return { outcome: 'pid-absent' } + }) + const host = await rig.boot({ savedStatus: undefined, ...deps }) + return { host, probe } +} + +describe('a startup reconcile that failed', () => { + it('settles a crashed chat nobody opened once a later reconcile succeeds', async () => { + await crashMidTurn('session-a') + const { host, probe } = await bootWithFailingReconcile({ + startupReconcileRetryDelaysMs: [60_000] + }) + await host.restoreStartupSessions() + // The pass opened it for status and closed it again; its turn is still read as running. + expect(isOpen(rig, 'session-a')).toBe(false) + expect(latestStatus(rig, 'session-a')?.status).toBe('working') + + probe.fail = false + await host.reconcileRestartLeases() + + await vi.waitFor(() => expect(latestStatus(rig, 'session-a')?.status).toBe('idle')) + expect(owesSettlement('session-a')).toBe(false) + }) + + it('retries the reconcile itself, so no attach is needed', async () => { + await crashMidTurn('session-a') + const { host, probe } = await bootWithFailingReconcile({ + startupReconcileRetryDelaysMs: [50, 50, 50] + }) + await host.restoreStartupSessions() + expect(latestStatus(rig, 'session-a')?.status).toBe('working') + const acquired = rig.acquire.mock.calls.length + + probe.fail = false + + await vi.waitFor(() => expect(latestStatus(rig, 'session-a')?.status).toBe('idle')) + expect(owesSettlement('session-a')).toBe(false) + expect(rig.acquire.mock.calls.length).toBe(acquired) + }) + + it('gives up after its retries, leaving the chat to its own attach', async () => { + await crashMidTurn('session-a') + const onEventSinkError = vi.fn() + const { host } = await bootWithFailingReconcile({ + startupReconcileRetryDelaysMs: [20, 20, 20], + onEventSinkError + }) + await host.restoreStartupSessions() + await vi.waitFor(() => + expect(onEventSinkError).toHaveBeenCalledWith({ + sessionId: 'startup-pass', + error: expect.objectContaining({ message: expect.stringContaining('after its retries') }) + }) + ) + expect(latestStatus(rig, 'session-a')?.status).toBe('working') + }) + + it('stops retrying once quit begins', async () => { + await crashMidTurn('session-a') + const { host } = await bootWithFailingReconcile({ startupReconcileRetryDelaysMs: [50, 50, 50] }) + await host.restoreStartupSessions() + await rig.crash() + const probes = rig.probeOwner.mock.calls.length + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(rig.probeOwner.mock.calls.length).toBe(probes) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts index d0ca18ba05b3..660752686834 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts @@ -176,31 +176,28 @@ describe('one chat at a time, readers first', () => { expect(isOpen(rig, 'session-y')).toBe(true) }) - it('closes what it opened only for status, never what a reader reached (L21)', async () => { - await rig.chat('session-p', { message: 'pass only' }) - await rig.chat('session-r', { message: 'reached' }) + it('leaves no journal open that only it opened, on a first boot where every chat misses (L21)', async () => { + const passOnly = ['session-p1', 'session-p2', 'session-p3', 'session-p4'] + for (const sessionId of passOnly) { + await rig.chat(sessionId, { message: sessionId }) + } await rig.chat('session-u', { message: 'user first' }) await rig.quit() const host = await rig.boot({ savedStatus: undefined }) await host.history({ sessionId: 'session-u', direction: 'tail' }) - const gate = gateOpens() - const pass = host.restoreStartupSessions() - await vi.waitFor(() => expect(gate.held.has('session-p')).toBe(true)) - gate.release('session-p') - await vi.waitFor(() => expect(gate.held.has('session-r')).toBe(true)) - const read = host.history({ sessionId: 'session-r', direction: 'tail' }) - gate.holding = false - gate.release('session-r') - await Promise.all([pass, read]) + await host.restoreStartupSessions() - expect(isOpen(rig, 'session-p')).toBe(false) - expect(isOpen(rig, 'session-r')).toBe(true) + for (const sessionId of passOnly) { + expect(rig.opensOf(sessionId)).toBe(1) + expect(isOpen(rig, sessionId)).toBe(false) + expect(rig.sink.publish).toHaveBeenCalledWith( + expect.objectContaining({ sessionId, latestPrompt: sessionId }), + expect.anything() + ) + } + // Opened by a reader before its step, so not the pass's to close. expect(isOpen(rig, 'session-u')).toBe(true) - expect(rig.sink.publish).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: 'session-p', latestPrompt: 'pass only' }), - expect.anything() - ) expect(rig.sink.forget).not.toHaveBeenCalled() }) }) From 3240ce61caa919f927edf429e715163f94366c31 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:36:23 -0700 Subject: [PATCH 092/101] fix(native-chat): back off a saved-status flush that keeps failing --- .../agent-session-saved-status-store.test.ts | 29 +++++++++++++++++++ .../agent-session-saved-status-store.ts | 16 +++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/agent-session-saved-status-store.test.ts b/src/main/runtime/agent-session-saved-status-store.test.ts index a6e03a019268..96d22da7f323 100644 --- a/src/main/runtime/agent-session-saved-status-store.test.ts +++ b/src/main/runtime/agent-session-saved-status-store.test.ts @@ -153,6 +153,35 @@ describe('saved chat status store', () => { expect(reopened.read('after')).toEqual(saved(2)) }) + it('backs off a flush that keeps failing instead of retrying every 250 ms', () => { + vi.useFakeTimers() + try { + const store = open(tempDir()) + const exec = Database.prototype.exec + const begins = vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database.Database, + sql: string + ) { + if (sql === 'BEGIN IMMEDIATE') { + throw new Error('database or disk is full') + } + return exec.call(this, sql) + }) + const attempts = () => begins.mock.calls.filter(([sql]) => sql === 'BEGIN IMMEDIATE').length + store.record('s1', saved(1)) + vi.advanceTimersByTime(250) + expect(attempts()).toBe(1) + + store.record('s1', saved(2)) + vi.advanceTimersByTime(250) + expect(attempts()).toBe(1) + vi.advanceTimersByTime(250) + expect(attempts()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + it('leaves a newer build’s file untouched and reads or writes nothing from it', () => { const dir = tempDir() const path = join(dir, AGENT_SESSION_SAVED_STATUS_FILE) diff --git a/src/main/runtime/agent-session-saved-status-store.ts b/src/main/runtime/agent-session-saved-status-store.ts index a26089a3052a..aaff1182823f 100644 --- a/src/main/runtime/agent-session-saved-status-store.ts +++ b/src/main/runtime/agent-session-saved-status-store.ts @@ -18,6 +18,8 @@ import Database from '../sqlite/sync-database' export const AGENT_SESSION_SAVED_STATUS_FILE = 'agent-session-saved-status.db' const SCHEMA_VERSION = 1 const FLUSH_DELAY_MS = 250 +// A flush that keeps failing waits twice as long each time, up to 250 ms × 2^6 = 16 s. +const MAX_FLUSH_BACKOFF_STEPS = 6 const CREATE_TABLE = `CREATE TABLE saved_status ( session_id TEXT PRIMARY KEY, @@ -82,6 +84,7 @@ function removeDatabaseFiles(path: string): void { export class AgentSessionSavedStatusStore { private readonly dirty = new Map() private timer: ReturnType | null = null + private failedFlushes = 0 private constructor( private readonly path: string, @@ -156,10 +159,13 @@ export class AgentSessionSavedStatusStore { return } this.dirty.set(sessionId, saved) - this.timer ??= setTimeout(() => { - this.timer = null - this.flush() - }, FLUSH_DELAY_MS) + this.timer ??= setTimeout( + () => { + this.timer = null + this.flush() + }, + FLUSH_DELAY_MS * 2 ** Math.min(this.failedFlushes, MAX_FLUSH_BACKOFF_STEPS) + ) this.timer.unref?.() } @@ -195,11 +201,13 @@ export class AgentSessionSavedStatusStore { } } catch (error) { warn('flush skipped; the next flush retries', error) + this.failedFlushes += 1 if (isUnusableSqliteDatabaseError(error)) { this.recreate() } return false } + this.failedFlushes = 0 for (const [sessionId, saved] of entries) { if (this.dirty.get(sessionId) === saved) { this.dirty.delete(sessionId) From c62e8a5028b985971130df0757342d7b0aa2fc7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:36:26 -0700 Subject: [PATCH 093/101] fix(native-chat): close the saved-status store when teardown's first recovery drain fails --- ...red-agent-session-runtime-teardown.test.ts | 35 +++++++++++++++++++ ...ructured-agent-session-runtime-teardown.ts | 8 ++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/structured-agent-session-runtime-teardown.test.ts diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.test.ts b/src/main/runtime/structured-agent-session-runtime-teardown.test.ts new file mode 100644 index 000000000000..ff4a557b0175 --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime-teardown.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' + +function installed(waitForRecovery: () => Promise) { + const savedStatus = { close: vi.fn() } + const runtime = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown reads only `flushAllStreamedEvents` from the host. + host: { + flushAllStreamedEvents: vi.fn(async () => undefined) + } as unknown as InstalledRuntime['host'], + adapter: { closeAll: vi.fn(async () => undefined) }, + savedStatus, + waitForRecovery + } + return { runtime, savedStatus } +} + +describe('structured runtime teardown', () => { + it('closes the saved-status store last', async () => { + const { runtime, savedStatus } = installed(async () => undefined) + await tearDownRuntime(runtime, 'quit') + expect(savedStatus.close).toHaveBeenCalledTimes(1) + expect(savedStatus.close.mock.invocationCallOrder[0]).toBeGreaterThan( + runtime.adapter.closeAll.mock.invocationCallOrder[0]! + ) + }) + + it('still closes the saved-status store when the first recovery drain fails', async () => { + const { runtime, savedStatus } = installed(async () => { + throw new Error('recovery failed') + }) + await expect(tearDownRuntime(runtime, 'quit')).rejects.toThrow('recovery failed') + expect(savedStatus.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index f170ff65bb7f..4ceb1e912f80 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -36,7 +36,13 @@ export async function tearDownRuntime( ): Promise { // Drain an in-flight recovery before stopping children; recovery may still // be writing lifecycle rows or acquiring a replacement child. - await installed.waitForRecovery() + try { + await installed.waitForRecovery() + } catch (error) { + // Teardown stops here, but the settled statuses already recorded still reach disk. + installed.savedStatus?.close() + throw error + } const failures: unknown[] = [] // Host teardown runs FIRST, which inverts the older order. It is what stops this host's // provider children now: it evicts each owned session through the adapter, and that eviction From 790361f691c0c078e702080a7753f559ca304fbe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:39:52 -0700 Subject: [PATCH 094/101] test(native-chat): reach the host's session map through its typed test seam --- ...nt-session-startup-reconcile-retry.test.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts index 79b6f9247a6d..ca01c3e9b5fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts @@ -3,7 +3,6 @@ // settle every listed chat that owes it, not only the ones some reader happened to open. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { createStartupRig, @@ -32,17 +31,13 @@ async function crashMidTurn(sessionId: string): Promise { const host = await rig.boot() await host.restoreStartupSessions() await rig.chat(sessionId, { message: 'second' }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's session map, reached only to journal the provider's running turn as its event path would. - const sessions = ( - rig.host as unknown as { sessions: Map } - ).sessions - await sessions - .get(sessionId)! - .journal.appendItem( - { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 'turn-running', ordinal: 99 }, - { kind: 'turn', turnId: 'turn-running', state: 'running', startedAt: 1 }, - { fence: rig.store.getRecord(sessionId)!.lease.runtimeFence } - ) + const { journal } = rig.host.collaboratorsForTests().sessions.get(sessionId)! + // The provider's turn is still running when the app dies. + await journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 'turn-running', ordinal: 99 }, + { kind: 'turn', turnId: 'turn-running', state: 'running', startedAt: 1 }, + { fence: rig.store.getRecord(sessionId)!.lease.runtimeFence } + ) await rig.crash() } From 2b189a8b8a71fd502a357a6c39a1e634242dbc96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:47:49 -0700 Subject: [PATCH 095/101] test(native-chat): wait for the settled row and the cleared flag together --- ...d-agent-session-startup-reconcile-retry.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts index ca01c3e9b5fa..d617854c5e60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts @@ -68,8 +68,11 @@ describe('a startup reconcile that failed', () => { probe.fail = false await host.reconcileRestartLeases() - await vi.waitFor(() => expect(latestStatus(rig, 'session-a')?.status).toBe('idle')) - expect(owesSettlement('session-a')).toBe(false) + // The interrupted row lands before the settlement's record write clears the flag. + await vi.waitFor(() => { + expect(latestStatus(rig, 'session-a')?.status).toBe('idle') + expect(owesSettlement('session-a')).toBe(false) + }) }) it('retries the reconcile itself, so no attach is needed', async () => { @@ -83,8 +86,11 @@ describe('a startup reconcile that failed', () => { probe.fail = false - await vi.waitFor(() => expect(latestStatus(rig, 'session-a')?.status).toBe('idle')) - expect(owesSettlement('session-a')).toBe(false) + // The interrupted row lands before the settlement's record write clears the flag. + await vi.waitFor(() => { + expect(latestStatus(rig, 'session-a')?.status).toBe('idle') + expect(owesSettlement('session-a')).toBe(false) + }) expect(rig.acquire.mock.calls.length).toBe(acquired) }) From d7ce43ae2eaa84ad97e5053a44d0d4a08d6b7dd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:48:57 -0700 Subject: [PATCH 096/101] refactor(orchestration): owed work is an open dispatch on the worker's incarnation A supervised worker's own dispatch context stays open exactly while the worker is active, so the separate active-custody branch only repeated it. Owed work is now one fact, which also states the policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are written once at the top of the module. --- src/main/runtime/structured-worker-custody.ts | 65 +++++++++---------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/src/main/runtime/structured-worker-custody.ts b/src/main/runtime/structured-worker-custody.ts index c14fd958c616..54e42b24d800 100644 --- a/src/main/runtime/structured-worker-custody.ts +++ b/src/main/runtime/structured-worker-custody.ts @@ -5,6 +5,10 @@ * coordinators, never whether the worker's process runs: a worker at rest is still held, and mail * starts it. Routing, group addressing and `worker-show` ask whether it is addressable; the idle * sweep asks whether it still owes work. + * + * Two policy decisions live here and nowhere else: only `released` ends addressability (a release + * pending or in doubt still routes, as for a terminal worker), and a settled worker awaiting its + * coordinator's decision (`reclaimable`) owes no work, so it may rest. */ import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -12,8 +16,6 @@ import type { OrchestrationDb } from './orchestration/db' import type { WorkerDispatchState } from './orchestration/types' import { deriveWorkerTerminalListState, - type WorkerDispatchListState, - type WorkerTerminalListState, type WorkerTerminalResourceRow } from './orchestration/worker-terminal-ownership' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' @@ -72,17 +74,6 @@ function ownerState( ) } -function custodyState( - row: CustodyRow, - workerState: WorkerDispatchListState -): WorkerTerminalListState | null { - return deriveWorkerTerminalListState({ - workerState, - agentTerminalHandle: row.terminal_handle, - resource: row - }) -} - /** * The user still owns the chat and orchestration has not released the worker, as with a terminal * worker whose terminal closed. Null when ownership cannot be read. A released worker's chat stays @@ -94,17 +85,26 @@ export function structuredWorkerAddressable( row: CustodyRow | undefined ): boolean | null { const owned = structuredWorkerOwned(sessionId) + if (owned === null) { + return null + } // Release is read off the row alone, so an owner whose state is unreadable still answers. - return owned === null - ? null - : owned && (!row || custodyState(row, ownerState(db, row) ?? 'unsupervised') !== 'released') + const custody = row + ? deriveWorkerTerminalListState({ + workerState: ownerState(db, row) ?? 'unsupervised', + agentTerminalHandle: row.terminal_handle, + resource: row + }) + : null + return owned && custody !== 'released' } /** - * Work orchestration still owes on this worker, read per sweep tick: a resource whose custody is - * `active` (its worker-start dispatch has not settled, a stop in doubt included), or any unsettled - * task later dispatched to the same incarnation on a process this host owns a terminal for. - * A settled worker awaiting its coordinator's decision (`reclaimable`) may rest. + * Work orchestration still owes on this worker, read per sweep tick: any unsettled dispatch + * addressed to its incarnation, on a process this host owns a terminal for. That covers its own + * worker-start dispatch (whose context stays open while the worker is active, a stop in doubt + * included, because a supervised worker's context settles only with it) and any task later + * dispatched to it. A `reclaimable` worker's dispatch has settled, so it owes nothing. */ export function structuredWorkerOwesWork( db: OrchestrationDb | null, @@ -115,24 +115,19 @@ export function structuredWorkerOwesWork( return false } const incarnation = structuredWorkerProcessIncarnation(record.sessionId) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table is exactly its row shape. const owned = db.db .prepare( - `SELECT * FROM worker_terminal_resources - WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned'` + `SELECT 1 FROM worker_terminal_resources + WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned' LIMIT 1` ) - .all(incarnation, JSON.stringify(hostScope)) as WorkerTerminalResourceRow[] + .get(incarnation, JSON.stringify(hostScope)) return ( - owned.some((row) => { - const workerState = ownerState(db, row) - return workerState !== undefined && custodyState(row, workerState) === 'active' - }) || - (owned.length > 0 && - db.db - .prepare( - `SELECT 1 FROM dispatch_contexts - WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` - ) - .get(incarnation) !== undefined) + owned !== undefined && + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .get(incarnation) !== undefined ) } From 31c02971efa959667291b9da63f3d186bffbbe56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:55:20 -0700 Subject: [PATCH 097/101] test(native-chat): crashed-turn tests assert the unverifiable settlement the code produces --- ...uctured-agent-session-startup-pass.test.ts | 19 +++++++++++++++++-- ...nt-session-startup-reconcile-retry.test.ts | 15 +++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts index f2755ed2a2fd..507a095c04d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts @@ -42,6 +42,11 @@ function rowsOf(sessionId: string) { ) } +async function turnStates(sessionId: string): Promise { + const { items } = await rig.host.journalSnapshot(sessionId) + return items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.state] : [])) +} + function journalDir(sessionId: string): string { return journalDirectoryFor(rig.root, { workspaceId: 'workspace-1', sessionId }) } @@ -61,6 +66,14 @@ async function crashMidTurn(sessionId: string, text: string): Promise { }) await host.restoreStartupSessions() await rig.chat(sessionId, { message: text }) + await rig.host + .collaboratorsForTests() + .sessions.get(sessionId)! + .journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 'turn-running', ordinal: 99 }, + { kind: 'turn', turnId: 'turn-running', state: 'running', startedAt: 1 }, + { fence: rig.store.getRecord(sessionId)!.lease.runtimeFence } + ) // Every publish of the running turn has run, and the flush timer fired: whatever it left in the // saved copy is on disk. await new Promise((resolve) => setTimeout(resolve, 0)) @@ -164,10 +177,12 @@ describe('listing from a saved status', () => { status: 'idle', latestPrompt: 'second question' }) - // Never shown in between: neither the settled turn before it nor the turn as still running. + // The settled turn before it is never shown. expect(rowsOf('session-a').filter((row) => row.latestPrompt !== 'second question')).toEqual([]) - expect(rowsOf('session-a').filter((row) => row.status === 'working')).toEqual([]) expect(rig.store.getRecord('session-a')?.lease.settlementRetryRequired).toBeUndefined() + // Settled as `unverifiable`, so no verdict; the dead-generation crash verdict is a follow-up. + expect(latestStatus(rig, 'session-a')?.turnOutcome).toBeUndefined() + expect(await turnStates('session-a')).toEqual(['unverifiable']) }) it.each([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts index d617854c5e60..953d8dc6d5a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts @@ -3,6 +3,7 @@ // settle every listed chat that owes it, not only the ones some reader happened to open. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { createStartupRig, @@ -41,6 +42,14 @@ async function crashMidTurn(sessionId: string): Promise { await rig.crash() } +/** Settled as `unverifiable`, so no verdict; the dead-generation crash verdict is a follow-up. */ +function expectSettledUnverifiable(snapshot: { items: readonly AgentJournalRenderItem[] }): void { + expect(latestStatus(rig, 'session-a')?.turnOutcome).toBeUndefined() + expect( + snapshot.items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.state] : [])) + ).toEqual(['unverifiable']) +} + /** Boots with every owner probe failing until `probe.fail` is cleared. */ async function bootWithFailingReconcile(deps: Partial = {}) { const probe = { fail: true } @@ -68,11 +77,12 @@ describe('a startup reconcile that failed', () => { probe.fail = false await host.reconcileRestartLeases() - // The interrupted row lands before the settlement's record write clears the flag. + // The settled row lands before the settlement's record write clears the flag. await vi.waitFor(() => { expect(latestStatus(rig, 'session-a')?.status).toBe('idle') expect(owesSettlement('session-a')).toBe(false) }) + expectSettledUnverifiable(await rig.host.journalSnapshot('session-a')) }) it('retries the reconcile itself, so no attach is needed', async () => { @@ -86,11 +96,12 @@ describe('a startup reconcile that failed', () => { probe.fail = false - // The interrupted row lands before the settlement's record write clears the flag. + // The settled row lands before the settlement's record write clears the flag. await vi.waitFor(() => { expect(latestStatus(rig, 'session-a')?.status).toBe('idle') expect(owesSettlement('session-a')).toBe(false) }) + expectSettledUnverifiable(await rig.host.journalSnapshot('session-a')) expect(rig.acquire.mock.calls.length).toBe(acquired) }) From 9fa4452d18ec542a9d218708297880685d4626a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:00:30 -0700 Subject: [PATCH 098/101] fix(native-chat): a restart offer knows its continuations by a tag in their id The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running action's id in memory. Both could disagree with the journal: past the cap an old rejected continuation read as the chat moving on, and a crash during a retry restored the failure's older entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer (its teardown and chat), then the action's own part, so any continuation of this offer, queued or rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own continuation the start was for, read against the stored marker. --- ...t-session-restart-continuation-envelope.ts | 46 +++++--- ...t-session-restart-offer-withdrawal.test.ts | 100 ++++++++++++++---- ...-agent-session-restart-offer-withdrawal.ts | 47 ++++---- ...tured-agent-session-restart-resume-host.ts | 19 +--- .../agent-session-recovery-capsule-entries.ts | 14 --- .../agent-session-recovery-capsule.test.ts | 30 ------ .../runtime/agent-session-recovery-capsule.ts | 19 ++-- src/shared/agent-session-resume-marker.ts | 17 +-- ...ssion-resume-marker-downgrade.unit.test.ts | 9 +- 9 files changed, 146 insertions(+), 155 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts index 827a916048a2..cfa2c7b502ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts @@ -17,28 +17,40 @@ export function restartContinuationBody(marker: AgentSessionResumeMarker): Agent } } -/** One resume action's continuation: the same action sends it once, and a retry is a new action - * with a new message, never a replay of the one that failed. Dated by the action, not the quit: - * the ledger refuses a new id dated more than a day back, and an offer has no expiry. */ +const hex16 = (parts: readonly unknown[]): string => + createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + +/** Names the offer: every continuation any of its actions sends carries it, and nothing else does. */ +function restartOfferTag(marker: AgentSessionResumeMarker): string { + return hex16([marker.teardownId, marker.sessionId]) +} + +/** One resume action's continuation: the offer's tag, then the action's own part, so a retry is a + * new message, never a replay of the one that failed. Dated by the action, not the quit: the + * ledger refuses a new id dated more than a day back, and an offer has no expiry. */ export function restartContinuationId( - sessionId: string, marker: AgentSessionResumeMarker, operationId: string, actionAt: number ): string { - return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${createHash('sha256') - .update( - JSON.stringify([ - marker.teardownId, - sessionId, - marker.work.kind, - marker.work.id, - marker.providerHandleRoot, - operationId - ]) - ) - .digest('hex') - .slice(0, 32)}` + return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${restartOfferTag(marker)}${hex16([ + marker.teardownId, + marker.sessionId, + marker.work.kind, + marker.work.id, + marker.providerHandleRoot, + operationId + ])}` +} + +/** Whether a message is a continuation one of this offer's resume actions sent, read off its id. */ +export function isRestartContinuationOf( + marker: AgentSessionResumeMarker, + clientMessageId: string +): boolean { + return /^\d{13}-[0-9a-f]{32}$/.test(clientMessageId) + ? clientMessageId.slice(14, 30) === restartOfferTag(marker) + : false } /** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ 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 449a717ed32c..f4c327df01ac 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 @@ -2,7 +2,8 @@ // agent started, other than by the offer's own continuation. Each case reads the offer list and the // capsule, never only an in-memory set. -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -19,6 +20,7 @@ import { import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' import { sweepOnce } from './structured-agent-session-rest-test-rig' import { HOST_TEST_NOW as NOW, @@ -95,7 +97,10 @@ it("does not let the offer's own continuation withdraw it (R-03)", async () => { expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledOnce() - expect(dismiss).not.toHaveBeenCalled() + // The start was the continuation's own: whatever asked, it withdrew nothing. + for (const call of dismiss.mock.results) { + expect(await call.value).toBe(0) + } // Ended by the success path instead. expect(await offersIn(root)).toEqual([]) }) @@ -384,11 +389,19 @@ it('delivers a clicked continuation and a message sent right after it, in the or describe('reading the chat against where the offer was taken', () => { const TAKEN = { epoch: 'epoch-1', sequence: 5 } + const OFFER = { + sessionId: SESSION, + work: { kind: 'turn' as const, id: 'turn-1' }, + recordedAt: NOW, + trigger: 'quit' as const, + providerHandleRoot: 'codex:"thread"', + teardownId: 'teardown-1' + } + function movedOn( input: { epoch?: string submissions?: { clientMessageId: string; acceptedSequence: number; dispatchState: string }[] - continuations?: string[] }, journalCursor: { epoch: string; sequence: number } | null = TAKEN ): boolean { @@ -405,16 +418,7 @@ describe('reading the chat against where the offer was taken', () => { now: () => NOW, enqueue: (operation) => operation() }) - return withdrawal.movedOn({ - sessionId: SESSION, - work: { kind: 'turn', id: 'turn-1' }, - recordedAt: NOW, - trigger: 'quit', - providerHandleRoot: 'codex:"thread"', - teardownId: 'teardown-1', - ...(journalCursor ? { journalCursor } : {}), - ...(input.continuations ? { continuations: input.continuations } : {}) - }) + return withdrawal.movedOn({ ...OFFER, ...(journalCursor ? { journalCursor } : {}) }) } it('counts a message accepted after that position, and nothing before it', () => { @@ -431,12 +435,70 @@ describe('reading the chat against where the offer was taken', () => { expect(movedOn({ epoch: 'epoch-2' })).toBe(true) }) - it('does not count a rejected continuation, so a retry still runs', () => { - const rejected = { clientMessageId: 'c-1', acceptedSequence: 7, dispatchState: 'rejected' } - expect(movedOn({ submissions: [rejected], continuations: ['c-1'] })).toBe(false) - expect( - movedOn({ submissions: [{ ...rejected, dispatchState: 'accepted' }], continuations: ['c-1'] }) - ).toBe(true) + it("does not count the offer's own continuation while queued or rejected, so a retry still runs", () => { + const own = restartContinuationId(OFFER, 'operation-1', NOW) + const rejected = { clientMessageId: own, acceptedSequence: 7, dispatchState: 'rejected' } + expect(movedOn({ submissions: [rejected] })).toBe(false) + expect(movedOn({ submissions: [{ ...rejected, dispatchState: 'pending' }] })).toBe(false) + expect(movedOn({ submissions: [{ ...rejected, dispatchState: 'accepted' }] })).toBe(true) + // Another offer's continuation, and any other message, is the chat moving on. + const other = restartContinuationId({ ...OFFER, teardownId: 'teardown-2' }, 'operation-1', NOW) + expect(movedOn({ submissions: [{ ...rejected, clientMessageId: other }] })).toBe(true) + }) + + // The retry's reservation lapses with the app, and the capsule hands back the failure's marker, + // written before the retry ran. The next launch rejects the retry's queued continuation. + it('keeps a failure retryable after the app crashed during its retry', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-crash-during-retry-')) + try { + const capsule = new AgentSessionRecoveryCapsule(root) + await capsule.record([{ ...OFFER, journalCursor: TAKEN }], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + await capsule.failResume( + 'operation-a', + [ + { + sessionId: SESSION, + failedAt: NOW, + outcome: 'refused', + reason: 'x', + latestPrompt: '', + latestUserItemId: null + } + ], + NOW + ) + const [retrying] = await capsule.beginResume([SESSION], 'operation-b', NOW + 1) + if (!retrying) { + throw new Error('the retry reserved nothing') + } + const retry = restartContinuationId(retrying, 'operation-b', NOW + 1) + const afterCrash = NOW + 60 * 60_000 + expect(await capsule.list(afterCrash)).toEqual([]) + const [failed] = await capsule.listFailed(afterCrash) + + const session = { + child: null, + journal: { + cursor: () => ({ epoch: TAKEN.epoch, sequence: 9 }), + submissions: () => [ + { clientMessageId: retry, acceptedSequence: 7, dispatchState: 'rejected' } + ] + } + } + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. + sessions: new Map([[SESSION, session as never]]), + now: () => afterCrash, + enqueue: (operation) => operation() + }) + if (!failed) { + throw new Error('the failure did not outlive the crash') + } + expect(withdrawal.movedOn(failed.marker)).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } }) it('leaves an older build’s offer, which recorded no position, to a start', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 241a96255e99..020fb4bde511 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -11,6 +11,7 @@ import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { isRestartContinuationOf } from './structured-agent-session-restart-continuation-envelope' export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< typeof createStructuredAgentSessionRestartOfferWithdrawal @@ -28,15 +29,11 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise }) { - /** The continuation each running resume action sends, by chat. */ - const actions = new Map() - const movedOn = (marker: AgentSessionResumeMarker): boolean => { const session = deps.sessions.get(marker.sessionId) if (!session) { return false } - const own = actions.get(marker.sessionId) const taken = marker.journalCursor // An older build's offer recorded no position: only a start withdraws it. const accepted = @@ -44,13 +41,12 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { (session.journal.cursor().epoch !== taken.epoch || session.journal.submissions().some( (submission) => - submission.clientMessageId !== own && (submission.acceptedSequence ?? 0) > taken.sequence && - // A continuation the offer sent that was rejected never reached the agent: a retry - // sends a new one. + // The offer's own continuation, still queued or rejected, never reached the agent: a + // retry sends a new one. !( - submission.dispatchState === 'rejected' && - marker.continuations?.includes(submission.clientMessageId) + (submission.dispatchState === 'pending' || submission.dispatchState === 'rejected') && + isRestartContinuationOf(marker, submission.clientMessageId) ) )) // Proven, not merely spawned: a start that failed during startup never ran the agent. Whose @@ -61,34 +57,31 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { : session.lastEndedChild?.duringStartup === false ? session.lastEndedChild : undefined - const ownStart = - started?.startedFor !== undefined && - (started.startedFor === own || marker.continuations?.includes(started.startedFor) === true) - return accepted || (started !== undefined && !ownStart) + return ( + accepted || + (started !== undefined && + !(started.startedFor !== undefined && isRestartContinuationOf(marker, started.startedFor))) + ) } return { movedOn, - /** A resume action holds the chat from its reservation until it settles. */ - begin: (sessionId: string, continuationId: string): (() => void) => { - actions.set(sessionId, continuationId) - return () => { - if (actions.get(sessionId) === continuationId) { - actions.delete(sessionId) - } - } - }, - /** The chat's agent proved a start: unless it is a resume action's own, the offer and any - * failure record go from the recovery file. Advisory: a failed write is logged, never raised. */ + /** The chat's agent proved a start: the offer and any failure record go from the recovery file, + * unless the start was for one of that offer's own continuations. Advisory: a failed write is + * logged, never raised. */ onAgentStarted: (sessionId: string): void => { const session = deps.sessions.get(sessionId) const capsule = deps.capsule - const own = actions.get(sessionId) - if (!capsule || !session || (own !== undefined && session.child?.startedFor === own)) { + if (!capsule || !session) { return } + const startedFor = session.child?.startedFor void deps - .enqueue(() => capsule.dismiss([sessionId], deps.now())) + .enqueue(() => + capsule.dismiss([sessionId], deps.now(), (marker) => + startedFor === undefined ? false : isRestartContinuationOf(marker, startedFor) + ) + ) .catch(() => { console.warn('[structured-agent-session] withdrawing a restart offer failed') }) 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 6d1d4aa1be61..058f6bc7c957 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 @@ -164,25 +164,20 @@ export function createStructuredAgentSessionRestartResume( } const operationId = randomUUID() const actionAt = surfaces.now() - // Recorded on the offer as it is reserved, so a rejected one is told apart for as long as the - // offer lasts. + // Tagged with its offer, so a rejected one is told apart for as long as the offer lasts. const continuationFor = (marker: AgentSessionResumeMarker) => - restartContinuationId(marker.sessionId, marker, operationId, actionAt) + restartContinuationId(marker, operationId, actionAt) const reserved = (await enqueueRecoveryOperation( () => deps.recoveryCapsule?.beginResume( eligible.map((candidate) => candidate.sessionId), operationId, - actionAt, - continuationFor + actionAt ) ?? Promise.resolve([]) )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates - const releases = reserved.map((marker) => - withdrawal.begin(marker.sessionId, continuationFor(marker)) - ) try { const outcomes = await resumeStructuredAgentSessionsFromRestart( { @@ -201,11 +196,8 @@ export function createStructuredAgentSessionRestartResume( candidates, owner ) - return { operationId, outcomes, candidates, markers: markersBySession, releases } + return { operationId, outcomes, candidates, markers: markersBySession } } catch (error) { - for (const release of releases) { - release() - } if (deps.recoveryCapsule) { await enqueueRecoveryOperation(() => deps.recoveryCapsule!.rollbackResume(operationId, surfaces.now()) @@ -249,9 +241,6 @@ export function createStructuredAgentSessionRestartResume( await Promise.all(verdicts) const resumed = action?.outcomes ?? [] if (action) { - for (const release of action.releases) { - release() - } await failures.settle(action.operationId, resumed, { candidates: action.candidates, markers: action.markers, diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index 6f821b5b2dc3..de8a452c87a2 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -7,7 +7,6 @@ import { z } from 'zod' import { AGENT_SESSION_RESUME_FAILURE_OUTCOMES, - AGENT_SESSION_RESUME_MAX_CONTINUATIONS, parseAgentSessionResumeMarker, type AgentSessionResumeFailureOutcome, type AgentSessionResumeMarker @@ -182,19 +181,6 @@ export function normalizeState( return { entries: [...bySession.values()], failed } } -/** The offer with one more continuation sent for it, keeping the newest the marker is bounded to. */ -export function markerWithContinuation( - marker: AgentSessionResumeMarker, - continuationId: string -): AgentSessionResumeMarker { - return { - ...marker, - continuations: [...(marker.continuations ?? []), continuationId].slice( - -AGENT_SESSION_RESUME_MAX_CONTINUATIONS - ) - } -} - export function shouldReplaceMarker( current: AgentSessionResumeMarker, incoming: AgentSessionResumeMarker diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 120a2e28484b..2150e5c7d516 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -9,7 +9,6 @@ import { NOW, SESSION } from '../native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness' -import { AGENT_SESSION_RESUME_MAX_CONTINUATIONS } from '../../shared/agent-session-resume-marker' import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE, @@ -187,35 +186,6 @@ describe('durable restart offers', () => { expect(await capsule.listFailed(later)).toHaveLength(1) }) - it("records each action's continuation on the offer it reserves and files it with the failure", async () => { - await capsule.record([marker()], NOW) - const continuationFor = (operationId: string) => () => `continuation-${operationId}` - - expect( - await capsule.beginResume([SESSION], 'operation-a', NOW, continuationFor('operation-a')) - ).toEqual([marker({ continuations: ['continuation-operation-a'] })]) - await capsule.failResume('operation-a', [failure()], NOW) - await capsule.beginResume([SESSION], 'operation-b', NOW + 1, continuationFor('operation-b')) - await capsule.failResume('operation-b', [failure({ failedAt: NOW + 1 })], NOW + 1) - - expect((await capsule.listFailed(NOW + 1))[0]?.marker.continuations).toEqual([ - 'continuation-operation-a', - 'continuation-operation-b' - ]) - }) - - it('keeps only the newest continuations an offer is bounded to', async () => { - await capsule.record([marker()], NOW) - for (let attempt = 0; attempt <= AGENT_SESSION_RESUME_MAX_CONTINUATIONS; attempt += 1) { - await capsule.beginResume([SESSION], `operation-${attempt}`, NOW, () => `c-${attempt}`) - await capsule.rollbackResume(`operation-${attempt}`, NOW) - } - - const [offer] = await capsule.list(NOW) - expect(offer?.continuations).toHaveLength(AGENT_SESSION_RESUME_MAX_CONTINUATIONS) - expect(offer?.continuations?.at(-1)).toBe(`c-${AGENT_SESSION_RESUME_MAX_CONTINUATIONS}`) - }) - it('refiles a failed retry with its new reason', async () => { await capsule.record([marker()], NOW) await fileFailure() diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 27f6543f48ca..80ed0bb726e2 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -12,7 +12,6 @@ import { import { withFileTransactionLock } from '../file-transaction-lock' import { MAX_FAILURE_FIELD_LENGTH, - markerWithContinuation, normalizeState, parseState, shouldReplaceMarker, @@ -104,19 +103,14 @@ export class AgentSessionRecoveryCapsule { beginResume( sessionIds: readonly string[] | undefined, operationId: string, - now: number, - /** The continuation this action sends for a marker, recorded on the offer it reserves. */ - continuationFor?: (marker: AgentSessionResumeMarker) => string + now: number ): Promise { return withFileTransactionLock(this.filePath, async () => { const state = await this.readState() const { entries, failed } = normalizeState(state, now) const requested = sessionIds === undefined ? null : new Set(sessionIds) const selected: AgentSessionResumeMarker[] = [] - const reserve = (offer: AgentSessionResumeMarker): RecoveryEntry => { - const marker = continuationFor - ? markerWithContinuation(offer, continuationFor(offer)) - : offer + const reserve = (marker: AgentSessionResumeMarker): RecoveryEntry => { selected.push(marker) return { state: 'in-progress', operationId, startedAt: now, marker } } @@ -207,15 +201,20 @@ export class AgentSessionRecoveryCapsule { /** Forgets the named sessions whatever their state. Unlike `clearAll`, this is not a fence: a * later teardown of the same chat may record a fresh offer. */ - dismiss(sessionIds: readonly string[], now: number): Promise { + dismiss( + sessionIds: readonly string[], + now: number, + /** A record this answers true for stays: read against the stored marker, under the lock. */ + keep: (marker: AgentSessionResumeMarker) => boolean = () => false + ): Promise { return withFileTransactionLock(this.filePath, async () => { const named = new Set(sessionIds) const state = await this.readState() const { entries, failed } = normalizeState(state, now) const dismissed = new Set( [...entries, ...failed] + .filter((record) => named.has(record.marker.sessionId) && !keep(record.marker)) .map((record) => record.marker.sessionId) - .filter((sessionId) => named.has(sessionId)) ) if (dismissed.size > 0) { await this.publish( diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts index a8827339aa1e..3890bd8f81d3 100644 --- a/src/shared/agent-session-resume-marker.ts +++ b/src/shared/agent-session-resume-marker.ts @@ -74,12 +74,6 @@ export type AgentSessionResumeMarker = { * record it. */ journalCursor?: AgentJournalCursor - /** - * The client message ids of the continuations this offer's resume actions sent, newest last. - * A rejected one never reached the agent, so it is not the chat moving on and a retry still - * runs. On the offer itself, so the ids end with it. Absent until an action runs. - */ - continuations?: string[] /** * What the session was doing, captured at the same stop-time snapshot that decided the offer. * The dialog row, the status bar and the wire candidate read ONLY this; nothing re-reads the @@ -97,9 +91,6 @@ const MAX_FIELD_LENGTH = 512 /** Bounded because a marker is read back from a file this process did not necessarily write. */ const markerField = z.string().min(1).max(MAX_FIELD_LENGTH) -/** Retries an offer remembers; an older rejected one past this counts as the chat moving on. */ -export const AGENT_SESSION_RESUME_MAX_CONTINUATIONS = 16 - const agentSessionResumeWorkSchema = z.object({ kind: z.enum(['turn', 'submission']), id: markerField @@ -141,11 +132,6 @@ const agentSessionResumeMarkerSchema = z.object({ .object({ epoch: markerField, sequence: z.number().int().nonnegative() }) .optional() .catch(undefined), - continuations: z - .array(markerField) - .max(AGENT_SESSION_RESUME_MAX_CONTINUATIONS) - .optional() - .catch(undefined), activity: agentSessionRestartActivitySchema.optional().catch(undefined) }) @@ -156,11 +142,10 @@ export function parseAgentSessionResumeMarker(value: unknown): AgentSessionResum if (!parsed.success) { return null } - const { activity, journalCursor, continuations, ...marker } = parsed.data + const { activity, journalCursor, ...marker } = parsed.data return { ...marker, ...(journalCursor === undefined ? {} : { journalCursor }), - ...(continuations === undefined ? {} : { continuations }), ...(activity === undefined ? {} : { activity }) } } diff --git a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts index 0d820fc3b7a9..f7f23d528aaf 100644 --- a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts @@ -17,7 +17,7 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // A release whose marker parser still requires the chat's newest message id. const BASELINE_REF = 'v1.4.211' -test('an older build reads the restart offer this build records, continuations and all', async () => { +test('an older build reads the restart offer this build records at quit', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-resume-marker-downgrade-')) try { // This build: the offer teardown takes for a chat whose turn was running. @@ -33,12 +33,7 @@ test('an older build reads the restart offer this build records, continuations a if (!marker) { throw new Error('this build recorded no offer for a running turn') } - const capsule = new AgentSessionRecoveryCapsule(directory) - await capsule.record([marker], NOW) - // A resume action that did not finish leaves its continuation recorded on the offer. - await capsule.beginResume([SESSION], 'operation-1', NOW, () => 'continuation-1') - await capsule.rollbackResume('operation-1', NOW) - expect(await capsule.list(NOW)).toMatchObject([{ continuations: ['continuation-1'] }]) + await new AgentSessionRecoveryCapsule(directory).record([marker], NOW) // The older build, after a downgrade, still lists it. const checkout = await materializeReleaseCheckout(BASELINE_REF) From 0c02d8a2d653c0f0ad564d040104db005823ae80 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:41:16 -0700 Subject: [PATCH 099/101] fix(native-chat): the chat tab sync subscribes even when the startup tab restore failed Startup no longer waits on the tab restore, so a failed one no longer stops the app; it reached only the sync, which then never subscribed and missed every host tab change for the run. The subscription's own retry now re-asks the host instead. --- ...-session-tabs-sync-startup-restore.test.ts | 50 +++++++++++++++++++ .../subscription.ts | 6 ++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync-startup-restore.test.ts diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync-startup-restore.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync-startup-restore.test.ts new file mode 100644 index 000000000000..e1c3ff805799 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync-startup-restore.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom + +import { expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + restoreLocalStructuredSessionTabsOnce, + startLocalStructuredSessionTabsSync +} from './local-structured-session-tabs-sync' + +it('still subscribes when the startup restore it shares with hydration fails', async () => { + const priorApi = window.api + let answerFirstListing = (_response: unknown): void => undefined + const firstListing = new Promise((resolve) => { + answerFirstListing = resolve + }) + const call = vi + .fn() + .mockReturnValueOnce(firstListing) + .mockResolvedValue({ ok: true, result: { snapshots: [] } }) + const subscribe = vi.fn(async () => ({ unsubscribe: vi.fn() })) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + runtime: { + getStatus: vi.fn().mockResolvedValue({ + capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + call, + subscribe + } + } + }) + try { + // Hydration fires the restore without waiting, then the sync joins that same attempt. + void restoreLocalStructuredSessionTabsOnce().catch(() => undefined) + const started = startLocalStructuredSessionTabsSync({ + isDisposed: () => false, + setUnsubscribe: () => undefined + }) + // The sync has joined the pending restore by now, so the failure reaches it too. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(call).toHaveBeenCalledOnce() + answerFirstListing({ ok: false, error: { code: 'runtime_error', message: 'install failed' } }) + await started + + expect(subscribe).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(window, 'api', { configurable: true, value: priorApi }) + } +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts index 29fc6230629b..0e773182edfd 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts @@ -41,7 +41,11 @@ export async function startLocalStructuredSessionTabsSync(args: { return } const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - await restoreLocalStructuredSessionTabsOnce(syncGeneration) + // Startup no longer waits on this restore, so its failure lands here: the subscription below + // re-asks the host, and its retry refreshes the inventory first. + await restoreLocalStructuredSessionTabsOnce(syncGeneration).catch((error: unknown) => { + console.warn('[structured-session-tabs] startup restore failed', error) + }) if (!isCurrent()) { return } From 5961a7da366c203f4edf686465afe741d59ddaf0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:48:05 -0700 Subject: [PATCH 100/101] fix(native-chat): saved statuses publish for the active workspace first The startup pass ordered only its opens by workspace; the saved-copy check that publishes most rows ran in index order, so with hundreds of chats the workspace on screen could be the last to fill in. --- ...tructured-agent-session-restart-restore.ts | 15 ++++++--------- ...uctured-agent-session-startup-pass.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 5794dad8c113..5a431e3f62bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -8,8 +8,8 @@ // opens when something reads it. // // One pass, kicked once after the host installs, in two phases: -// 1. before reconcile: each listed chat's saved status is checked against its journal's position; -// a match publishes the row, anything else is remembered as a miss; +// 1. before reconcile, active workspace first: each listed chat's saved status is checked against +// its journal's position; a match publishes the row, anything else is remembered as a miss; // 2. after reconcile: recovery exits, then settlement of every open conversation, then each miss // and each listed record still owing settlement is opened (unless already open), settled, and // closed again when closable. A startup reconcile that fails leaves that half owed: the first @@ -145,6 +145,8 @@ export function createStructuredAgentSessionStartupPass( let afterFailure: Promise | null = null const listed = (sessionId: string): boolean => deps.store.listVisibleSessionIds().includes(sessionId) + const workspaceOf = (sessionId: string) => deps.store.getRecord(sessionId)?.location.workspaceId + const inOrder = (ids: readonly string[]) => orderStartupSessionIds(ids, workspaceOf, priority) const supportedRecord = (sessionId: string): AgentSessionRecord | null => { const record = deps.store.getRecord(sessionId) return record && deps.supportsRecord(record) ? record : null @@ -231,12 +233,7 @@ export function createStructuredAgentSessionStartupPass( const owed = deps.store .listVisibleSessionIds() .filter((sessionId) => structuredAgentSessionOwesSettlement(deps.store.getRecord(sessionId))) - const targets = orderStartupSessionIds( - [...new Set([...misses, ...owed])], - (sessionId) => deps.store.getRecord(sessionId)?.location.workspaceId, - priority - ) - for (const sessionId of targets) { + for (const sessionId of inOrder([...new Set([...misses, ...owed])])) { // Logged once, never retried by the pass: the first read reports it. await openAndSettle(sessionId).catch((error: unknown) => deps.onError(sessionId, error)) await yieldToEventLoop() @@ -280,7 +277,7 @@ export function createStructuredAgentSessionStartupPass( const startedAt = deps.now() const misses: string[] = [] let seeded = 0 - for (const sessionId of deps.store.listVisibleSessionIds()) { + for (const sessionId of inOrder(deps.store.listVisibleSessionIds())) { await deps .step(sessionId, async ({ session }) => { const record = supportedRecord(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts index 507a095c04d4..8a3a9ecef411 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-pass.test.ts @@ -101,6 +101,25 @@ describe('listing from a saved status', () => { }) }) + it('publishes saved rows for the active workspace first, then the visible ones', async () => { + await rig.chat('session-r', { workspaceId: 'ws-rest', message: 'rest' }) + await rig.chat('session-v', { workspaceId: 'ws-visible', message: 'visible' }) + await rig.chat('session-x', { workspaceId: 'ws-active', message: 'active' }) + await rig.quit() + const host = await rig.boot() + + await host.restoreStartupSessions({ + activeWorkspaceId: 'ws-active', + visibleWorkspaceIds: new Set(['ws-visible']) + }) + + expect(['session-r', 'session-v', 'session-x'].map((id) => rig.opensOf(id))).toEqual([0, 0, 0]) + const firstRows = rig.statusEvents.flatMap((event) => + event.type === 'status' ? [event.session.sessionId] : [] + ) + expect([...new Set(firstRows)]).toEqual(['session-x', 'session-v', 'session-r']) + }) + it('opens a chat whose saved copy an older projection version wrote (C3)', async () => { await rig.chat('session-a', { message: 'first question' }) await rig.quit() From efefb803ed058c9f1e281b23e7f1d08789078ec7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:48:42 -0700 Subject: [PATCH 101/101] test(native-chat): give the unawaited post-reconcile settlement room on a loaded runner L19 and the startup-reconcile retry tests waited 1 s for a settlement a clean reconcile starts but does not await; L19 missed that window twice under load. --- ...nt-session-startup-reconcile-retry.test.ts | 19 ++++++++++++------- ...d-agent-session-startup-settlement.test.ts | 3 ++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts index 953d8dc6d5a4..1718747a6f4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-retry.test.ts @@ -22,6 +22,9 @@ afterEach(async () => { await rig.dispose() }) +// The settlement a clean reconcile starts is not awaited by it; allow for a loaded runner. +const SETTLED_WITHIN = { timeout: 10_000 } + const owesSettlement = (sessionId: string) => rig.store.getRecord(sessionId)?.lease.settlementRetryRequired === true @@ -81,7 +84,7 @@ describe('a startup reconcile that failed', () => { await vi.waitFor(() => { expect(latestStatus(rig, 'session-a')?.status).toBe('idle') expect(owesSettlement('session-a')).toBe(false) - }) + }, SETTLED_WITHIN) expectSettledUnverifiable(await rig.host.journalSnapshot('session-a')) }) @@ -100,7 +103,7 @@ describe('a startup reconcile that failed', () => { await vi.waitFor(() => { expect(latestStatus(rig, 'session-a')?.status).toBe('idle') expect(owesSettlement('session-a')).toBe(false) - }) + }, SETTLED_WITHIN) expectSettledUnverifiable(await rig.host.journalSnapshot('session-a')) expect(rig.acquire.mock.calls.length).toBe(acquired) }) @@ -113,11 +116,13 @@ describe('a startup reconcile that failed', () => { onEventSinkError }) await host.restoreStartupSessions() - await vi.waitFor(() => - expect(onEventSinkError).toHaveBeenCalledWith({ - sessionId: 'startup-pass', - error: expect.objectContaining({ message: expect.stringContaining('after its retries') }) - }) + await vi.waitFor( + () => + expect(onEventSinkError).toHaveBeenCalledWith({ + sessionId: 'startup-pass', + error: expect.objectContaining({ message: expect.stringContaining('after its retries') }) + }), + SETTLED_WITHIN ) expect(latestStatus(rig, 'session-a')?.status).toBe('working') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts index 660752686834..fcb2a7658134 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-settlement.test.ts @@ -117,7 +117,8 @@ describe('settling what a restart left owed', () => { reconcileFails = false await host.reconcileRestartLeases() - await vi.waitFor(() => expect(owesSettlement('session-a')).toBe(false)) + // The settlement a clean reconcile starts is not awaited by it; allow for a loaded runner. + await vi.waitFor(() => expect(owesSettlement('session-a')).toBe(false), { timeout: 10_000 }) }) })