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 01/64] 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 02/64] 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 03/64] 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 04/64] 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 05/64] 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 06/64] 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 07/64] 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 08/64] 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 09/64] 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 10/64] 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 11/64] 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 12/64] 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 13/64] 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 14/64] 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 15/64] 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 16/64] 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 17/64] 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 18/64] 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 19/64] 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 20/64] 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 21/64] 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 22/64] 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 23/64] 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 24/64] 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 25/64] 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 26/64] 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 27/64] 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 28/64] 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 29/64] 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 30/64] 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 31/64] 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 32/64] 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 33/64] 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 34/64] 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 35/64] 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 36/64] 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 37/64] 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 38/64] 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 39/64] 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 40/64] 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 41/64] 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 42/64] 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 43/64] 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 44/64] 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 45/64] 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 46/64] 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 47/64] 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 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 48/64] 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 49/64] 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 50/64] 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 51/64] 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 52/64] 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 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 53/64] 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 c39151d838c86e6eddcab2ea1b813abc8be8e249 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:41:07 -0700 Subject: [PATCH 54/64] fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. --- .../structured-agent-session-delivery-loop.ts | 7 +- ...red-agent-session-hold-resume-race.test.ts | 3 + .../structured-agent-session-holds.test.ts | 5 + .../structured-agent-session-holds.ts | 10 +- .../structured-agent-session-host-lifetime.ts | 5 + .../structured-agent-session-host.ts | 3 +- ...structured-agent-session-provider-child.ts | 9 + ...tructured-agent-session-unexpected-exit.ts | 11 +- ...sion-view-start-after-failed-start.test.ts | 164 ++++++++++++++++++ 9 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts 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 da6c341ce872..e12acb3a12e9 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 @@ -26,6 +26,7 @@ import { oldestQueuedSubmission, recordStructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' +import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' export type StructuredAgentSessionDeliveryLoopDeps = { @@ -221,11 +222,9 @@ function startThatFailedWhileQueued( session: StructuredAgentSessionHostSession, oldest: NonNullable> ): StartFailure | null { - const ended = session.lastEndedChild + const ended = failedProviderChildStart(session) if ( - session.child || - !ended?.duringStartup || - ended.cause === 'user-stop' || + !ended || oldest.acceptedSequence === undefined || ended.endedAt.epoch !== session.journal.cursor().epoch || ended.endedAt.sequence < oldest.acceptedSequence 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 index 974a69b1c3c8..b1004f7e396b 100644 --- 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 @@ -29,6 +29,7 @@ function resumeHarness() { resume, serialize: keyedSerialize(), hasProviderChild: () => child, + lastStartFailed: () => false, hasOwedWork: () => turnActive, evict, graceMs: GRACE_MS @@ -212,6 +213,7 @@ describe('a surface leaving while its structured session resumes', () => { }, serialize: keyedSerialize(), hasProviderChild: () => child, + lastStartFailed: () => false, hasOwedWork: () => false, evict: async () => {}, graceMs: GRACE_MS @@ -245,6 +247,7 @@ describe('a surface leaving while its structured session resumes', () => { resume, serialize: keyedSerialize(), hasProviderChild: () => child, + lastStartFailed: () => false, hasOwedWork: () => false, evict: async () => {}, graceMs: GRACE_MS 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 9b6718e99575..822f36df5bf3 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 @@ -184,6 +184,7 @@ describe('holds', () => { resume, serialize: keyedSerialize(), hasProviderChild: () => child, + lastStartFailed: () => false, hasOwedWork: () => false, evict: async () => {}, graceMs: 1 @@ -214,6 +215,7 @@ describe('holds', () => { resume, serialize, hasProviderChild: () => child, + lastStartFailed: () => false, hasOwedWork: () => false, evict: async () => {}, graceMs: 1 @@ -244,6 +246,7 @@ describe('holds', () => { }, serialize, hasProviderChild: () => child, + lastStartFailed: () => false, hasOwedWork: () => false, evict: async () => {}, graceMs: 60_000 @@ -262,6 +265,7 @@ describe('holds', () => { resume: async () => ({ ok: true as const }), serialize: keyedSerialize(), hasProviderChild: () => false, + lastStartFailed: () => false, hasOwedWork: () => false, evict, graceMs: 1 @@ -281,6 +285,7 @@ describe('holds', () => { resume: async () => ({ ok: true as const }), serialize: keyedSerialize(), hasProviderChild: () => false, + lastStartFailed: () => false, hasOwedWork: () => false, evict: async () => {}, graceMs: 1 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 index 0acbccb6b55f..89f28b890ed7 100644 --- 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 @@ -6,7 +6,7 @@ // // 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, +// process exist — unless that session's last start died starting: only a send retries one. 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. // @@ -33,6 +33,8 @@ export type StructuredAgentSessionHoldsDeps = { serialize: (sessionId: string, task: () => Promise) => Promise /** Whether evicting this session would actually free anything. */ hasProviderChild: (sessionId: string) => boolean + /** The last start died starting; a surface does not retry it, the next send does. */ + lastStartFailed: (sessionId: string) => boolean hasOwedWork: (sessionId: string) => boolean evict: (sessionId: string) => Promise onError?: (input: { sessionId: string; error: unknown }) => void @@ -77,7 +79,11 @@ export class StructuredAgentSessionHolds { } let resumed: StructuredAgentSessionResumeOutcome try { - resumed = await this.deps.serialize(sessionId, () => this.ensureProviderChild(sessionId)) + resumed = await this.deps.serialize(sessionId, () => + this.deps.lastStartFailed(sessionId) + ? Promise.resolve({ ok: true as const }) + : this.ensureProviderChild(sessionId) + ) } catch (error) { this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) throw 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 bc11a0a8de90..49d8b24e6441 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 @@ -25,6 +25,7 @@ import type { } from './structured-agent-session-host-types' import { endProviderChild, + failedProviderChildStart, structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' @@ -275,6 +276,10 @@ export function createStructuredAgentSessionHolds( }, evict: close, hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), + lastStartFailed: (sessionId) => { + const session = context.sessions.get(sessionId) + return session !== undefined && failedProviderChildStart(session) !== null + }, // 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. Subagents, commands and monitors outlive the lead's turn inside the 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 11cd824d0060..6462fbe5ad79 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 @@ -167,7 +167,8 @@ export class StructuredAgentSessionHost { 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. */ + * provider child is what resumes one, unless its last start failed; a retained hold (a + * subscription) only keeps it. */ hold = ( sessionId: string, holderId: string, 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..6c469e4dacc8 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 @@ -58,6 +58,15 @@ export function endProviderChild( return true } +/** The conversation's last start died before it proved itself, and nothing started since. Only a + * send retries it: a view or an exit recovery would respawn into the same failure, adding a row. */ +export function failedProviderChildStart( + session: Pick +): StructuredAgentSessionEndedChild | null { + const ended = session.lastEndedChild + return !session.child && ended?.duringStartup && ended.cause !== 'user-stop' ? ended : null +} + function matchingChild( session: ChildBearer, identity: StructuredAgentSessionProviderChildIdentity 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 80ddf2e3549e..541daec01533 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,7 +1,10 @@ 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' +import { + endProviderChild, + failedProviderChildStart +} from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit, type StructuredAgentSessionLeaseStore @@ -152,8 +155,10 @@ 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)) { + if ( + failedProviderChildStart(session) || + !context.hasResumeCapableHolder(unexpectedEvent.sessionId) + ) { return null } return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts new file mode 100644 index 000000000000..aac234cd52bb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -0,0 +1,164 @@ +// A Claude chat whose CLI exits before it finishes starting leaves one red row per start. A view +// opening, or coming back to, a chat whose last start failed used to start the CLI again, so every +// look at the chat added an identical row. Only a send retries a failed start: it is the user asking. + +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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const SURFACE = 'desktop-chat:1' +const LAUNCH_FAILURE = + 'claude stream-json exited (code 1): qa-shim: simulated claude launch failure' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let claude: ReturnType +let lifecycle: Promise[] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-view-start-after-failed-start-')) + resetHostTestOperationIds() + lifecycle = [] + // Every start spawns, is published, and exits before it answers initialize. + claude = fakeClaude({ initDelayMs: 20, exitBeforeInit: LAUNCH_FAILURE }) + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${claude.connections.length + 1}`, + now: () => NOW + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** Waits until the adapter has published every exit it saw and the host settled each one. */ +async function settleExits(): Promise { + await vi.waitFor(async () => { + await adapter.drainObservedExits() + await Promise.all(lifecycle) + expect(host.journalSnapshot(SESSION).items.length).toBeGreaterThan(0) + }) + await Promise.all(lifecycle) +} + +/** The chat as it renders: the user's messages and the error rows, in journal order. */ +function timeline(): string[] { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'message' + ? ['message'] + : item.body.kind === 'status' && item.body.tone === 'error' + ? [item.body.text] + : [] + ) +} + +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 ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +describe('a fresh chat whose Claude start fails', () => { + it('shows one row for the start a send made, and a view never starts it again', async () => { + await expect( + host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + ).resolves.toMatchObject({ ok: true }) + await settleExits() + const startFailure = `The provider stopped before it finished starting: ${LAUNCH_FAILURE}.` + expect(timeline()).toEqual([startFailure]) + + // The chat's view binds after the create's CLI already died. + await host.hold(SESSION, SURFACE) + expect(claude.connections).toHaveLength(1) + + const sent = await send('reply with exactly: alpha') + await vi.waitFor(() => + expect( + host.journalSnapshot(SESSION).submissions.find((s) => s.clientMessageId === sent) + ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) + ) + await settleExits() + // The send's own start, once, and one row for it below the message. + expect(claude.connections).toHaveLength(2) + expect(timeline()).toEqual([startFailure, 'message', startFailure]) + + // Switching away and back re-takes the view's hold; it starts nothing and adds no row. + host.release(SESSION, SURFACE) + await host.hold(SESSION, SURFACE) + await settleExits() + expect(claude.connections).toHaveLength(2) + expect(timeline()).toEqual([startFailure, 'message', startFailure]) + }) +}) From 9613178eda29d6627be54a5452c087b6db474be5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:55:58 -0700 Subject: [PATCH 55/64] test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. --- ...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 e2f7952cd231..22004de31102 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 @@ -437,13 +437,15 @@ describe("a view's start that dies while a sent message waits on it", () => { 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. + // A client's attach lands after the first child's exit, before the loop's first step: a view + // no longer starts a child whose last start failed, but an attach still does. const sent = host.send(CALLER, params) + const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const exited = exit(currentChild(), EXIT, true) - const held = host.hold(SESSION, 'surface-2') + const attached = host.attach(CALLER, hostTestAttachParams(exitedFence + 1)) await sent await exited - await held + await expect(attached).resolves.toMatchObject({ ok: true }) const id = params.envelope.clientOperationId await eventually(() => expect(submission(id)?.dispatchState).not.toBe('pending')) From 022445952985fb1d4466031b1a1b557948706318 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:56:03 -0700 Subject: [PATCH 56/64] fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. --- ...uctured-agent-session-conversation-open.ts | 21 +++- ...ed-agent-session-readable-restorer.test.ts | 3 +- ...ured-agent-session-restart-restore.test.ts | 25 ++-- ...tructured-agent-session-restart-restore.ts | 14 +-- .../structured-agent-session-reveal.test.ts | 3 +- .../structured-agent-session-reveal.ts | 17 +-- ...agent-session-send-open-stale-turn.test.ts | 114 ++++++++++++++++++ 7 files changed, 151 insertions(+), 46 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts 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 ceaa951e4d75..19d049039878 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 @@ -2,7 +2,8 @@ // for an attach that finds none open. // // 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 +// an earlier host process handed over and left unanswered as in doubt, and settles what it left +// running — 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. @@ -19,6 +20,7 @@ import { } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -94,6 +96,23 @@ export async function openStructuredAgentSessionConversationJournal( } catch (error) { deps.onEventSinkError?.({ sessionId, error }) } + try { + // No child in this process writes to a journal nobody had open, so whatever it shows running + // belongs to a generation that is gone. Settled before any reader or child sees it — unless an + // acquisition holds the lease: it cleared the evidence, and settles from what it read before. + if (record.lease.claimStatus !== 'reserved' && record.lease.claimStatus !== 'live') { + await settleStaleStructuredAgentSessionState({ + journal: opened.journal, + sessionId, + fence, + acquisitionGeneration: null, + deathEvidence: record.lease.deathEvidence ?? null + }) + } + } catch (error) { + // Best effort: the next acquire re-derives it. + deps.onEventSinkError?.({ sessionId, error }) + } return { 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-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 0325cfe80dd7..d1cc9408a62e 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 @@ -29,8 +29,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, - onReadable: () => undefined, - settleStaleState: async () => undefined + onReadable: () => undefined }) await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b']) 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 35d9f3b14c0e..491a2670d0d2 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 @@ -55,8 +55,7 @@ describe('restart journal restoration', () => { resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, - onReadable: () => undefined, - settleStaleState: async () => undefined + onReadable: () => undefined }) await vi.waitFor(() => expect(active).toBe(4)) @@ -92,7 +91,11 @@ describe('restart journal restoration', () => { session: { journal: {}, params, child: null }, reset: null } - restoreRead.mockResolvedValue(restored) + // The open is what settles: it runs after recovery resolution and before the publish. + restoreRead.mockImplementation(async () => { + calls.push('open') + return restored + }) await restoreOneStructuredAgentSessionRead( { @@ -103,21 +106,17 @@ describe('restart journal restoration', () => { }, serialize: async (_sessionId, task) => task(), hasSession: () => false, - onReadable: () => { - calls.push('onReadable') - }, - settleStaleState: async (_sessionId, settled) => { - calls.push(settled === restored ? 'settleStaleState:restored' : 'settleStaleState') + onReadable: (_sessionId, readable) => { + calls.push(readable === restored ? 'onReadable:restored' : 'onReadable') } }, 'session-1' ) - expect(calls).toEqual(['resolveRecovery', 'settleStaleState:restored', 'onReadable']) + expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) }) it('does not settle again when a second restore finds the session already open', async () => { - const settleStaleState = vi.fn(async () => undefined) restoreRead.mockResolvedValue({ session: { journal: {}, params: {}, child: null }, reset: null @@ -130,12 +129,12 @@ describe('restart journal restoration', () => { resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => true, - onReadable: () => undefined, - settleStaleState + onReadable: () => undefined }, 'session-1' ) - expect(settleStaleState).not.toHaveBeenCalled() + // The open is where the settlement runs, and a session already open is not opened again. + expect(restoreRead).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 875f1eb522b8..789eb37f45cd 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 @@ -35,11 +35,6 @@ export type StructuredAgentSessionReadRestoreDeps = { sessionId: string, opened: OpenedStructuredAgentSessionConversation ) => Promise | void - /** Settles what a previous generation left running. Best effort: the next acquire re-derives it. */ - settleStaleState: ( - sessionId: string, - opened: OpenedStructuredAgentSessionConversation - ) => Promise } /** @@ -66,10 +61,7 @@ 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( - input: Pick< - StructuredAgentSessionReadRestoreDeps, - 'openDeps' | 'hasSession' | 'onReadable' | 'settleStaleState' - >, + input: Pick, sessionId: string ): Promise { if (input.hasSession(sessionId)) { @@ -80,9 +72,7 @@ export async function restoreOneStructuredAgentSessionReadUnderSerialize( if (!opened) { return } - // No child in this process writes to a journal with no map entry, so anything it shows running - // belongs to a generation that is gone. Settled before it is published, so no reader sees it run. - await input.settleStaleState(sessionId, opened) + // The open settled what a gone generation left running, so no reader sees it run. await input.onReadable(sessionId, opened) } 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 8c38b02f1aac..7ba1c7167696 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 @@ -61,8 +61,7 @@ function harness( hasSession: (sessionId) => live.has(sessionId), onReadable: (sessionId, restored) => { live.set(sessionId, restored) - }, - settleStaleState: async () => undefined + } }) return { restorer, live, serializedIds } } 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 443f1ca7ae0e..c6ea9c1f83b5 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 @@ -18,8 +18,6 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' /** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */ export async function revealStructuredAgentSession( @@ -59,7 +57,7 @@ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' | 'settleStaleState' + 'openDeps' | 'supportsRecord' > ): { restoreReadableSessions: (sessionIds?: readonly string[]) => Promise @@ -68,19 +66,6 @@ export function createStructuredAgentSessionHostRestore( const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - settleStaleState: async (sessionId, opened) => { - try { - await settleStaleStructuredAgentSessionState({ - journal: opened.session.journal, - sessionId, - fence: structuredAgentSessionConversationFence(deps.store, sessionId), - acquisitionGeneration: null, - deathEvidence: deps.store.getRecord(sessionId)?.lease.deathEvidence ?? null - }) - } catch (error) { - deps.onEventSinkError?.({ sessionId, error }) - } - }, ...wiring }) const gate = new StructuredAgentSessionRestartRestoreGate() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts new file mode 100644 index 000000000000..73ae278f67d5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -0,0 +1,114 @@ +// A send can be what opens a conversation this process has not read yet: a chat nobody has on +// screen after the app died, sent to from a phone or the CLI. Whatever that journal shows running +// belongs to a generation that is gone, so it is settled when the journal opens, not only when a +// new child starts: a start that then fails would leave the turn running for every reader. + +import { cp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const relaunchedRoots: string[] = [] + +afterEach(async () => { + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A host that dies mid-turn, relaunched over a copy of its files taken at the crash. */ +async function relaunchAfterCrashMidTurn() { + const dying = hostTestState() + await attach() + const events = dying.acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'crashed-turn', ordinal: 1 }, + { kind: 'turn', turnId: 'crashed-turn', state: 'running' } + ) + await dying.host.flushStreamedEvents(SESSION) + // An empty renewal queues behind every record write, so they are on disk. + await dying.store.renewLeases([]) + const relaunched = `${dying.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(dying.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const store = await AgentSessionRecordStore.open({ + directory: join(relaunched, 'store'), + hostId: 'local' + }) + const acquire = vi.fn(async () => { + throw new Error('claude: command not found') + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: relaunched, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + await host.reconcileRestartLeases() + replaceHostTestState({ store, host }) + return { host, acquire } +} + +function runningTurns(host: StructuredAgentSessionHost) { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => readAgentJournalTurn(item.body) ?? []) + .filter((turn) => turn.state === 'running') +} + +it('settles a turn a dead generation left running when a send opens the chat and its start fails', async () => { + const { host, acquire } = await relaunchAfterCrashMidTurn() + expect(host.hasSession(SESSION)).toBe(false) + + const body = hostTestMessage('sent to a chat nobody has open') + const sendEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: sendEnvelope, body })).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => + expect( + host + .journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === sendEnvelope.clientOperationId) + ).toMatchObject({ dispatchState: 'rejected' }) + ) + + expect(acquire).toHaveBeenCalledOnce() + expect(runningTurns(host)).toEqual([]) + await host.flushAllStreamedEvents() +}) + +it('settles the same turn when a reader opens the chat', async () => { + const { host } = await relaunchAfterCrashMidTurn() + + await host.revealSession(SESSION) + + expect(runningTurns(host)).toEqual([]) + await host.flushAllStreamedEvents() +}) From 2276cef4b0f7a83aa9e85815386e6051fc652e81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:59:59 -0700 Subject: [PATCH 57/64] test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. --- ...gent-session-provider-child-record.test.ts | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 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 22004de31102..10d60791781a 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 @@ -16,12 +16,19 @@ 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 { + providerStartupFailureOutcome, + unexpectedProviderExitOutcome +} from './structured-agent-session-dead-generation-settlement' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' 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_LOCATION, HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -296,20 +303,51 @@ describe('Stop on a child still proving its start', () => { }) }) -describe('settling an earlier child inside the attach for the next one', () => { - it("settles from the earlier child's death evidence and leaves the queued message to the new child (R1)", async () => { - // The earlier child exited; the released lease keeps only its death evidence. +describe('settling an earlier child before the next one takes its message', () => { + it("settles the earlier child's turn from its death evidence and leaves the queued message to the new child (R1)", async () => { + // The earlier child exited mid-turn; the released lease keeps only its death evidence. await store.transitionHandoff(SESSION, (record) => ({ ...record, lease: { ...record.lease, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } + deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW - 1_000 } } })) const releasedFence = store.getRecord(SESSION)!.lease.runtimeFence + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: HOST_TEST_LOCATION.workspaceId, + hostId: HOST_TEST_LOCATION.executionHostId, + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: journalDirectoryFor(root, { + workspaceId: HOST_TEST_LOCATION.workspaceId, + sessionId: SESSION + }) + }) + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, + { fence: releasedFence } + ) + await journal.close() const id = await accept('for the next child') await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + // The exit was observed, so its receipt ends the turn, and the chat says why it stopped. + const items = conversation()!.journal.snapshot().items + expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( + expect.objectContaining({ + turnId: 'earlier-turn', + state: 'interrupted', + completedAt: NOW - 1_000 + }) + ) + expect( + items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + ).toContain(unexpectedProviderExitOutcome('provider exited')) // Handed over at the new child's fence, which the attach reserved after settling. const newFence = store.getRecord(SESSION)!.lease.runtimeFence expect(newFence).toBeGreaterThan(releasedFence) From 9275869673f2650d243e7622c8880bd820713411 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:00:48 -0700 Subject: [PATCH 58/64] test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. --- ...tructured-agent-session-append-delivery.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 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 9f38c85755f5..eef9297e82a1 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 @@ -50,8 +50,16 @@ function liveReader() { submissions.push(...event.page.submissions) } } + const rows = new Map() + for (const item of items) { + if (item.body.kind === 'status') { + rows.set(item.itemId, item.body.text) + } + } return { statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), + /** Each status row as the chat renders it: its latest revision, once. */ + statusRows: [...rows.values()], submissions, batches: events.slice(opened).filter((event) => event.type === 'batch').length } @@ -157,9 +165,9 @@ describe('an open chat receives every row its journal commits', () => { ) ) // 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/)]) - ) + expect(pane.received().statusRows).toEqual([ + expect.stringMatching(/stopped before it finished starting: .*not signed in/) + ]) }) it('shows a revision the provider queued with no publish behind it', async () => { From 158ca0a5ee7f6f2bf4d3c354cc9494f0b437c186 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:50:46 -0700 Subject: [PATCH 59/64] test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget --- ...structured-agent-session-send-open-stale-turn.test.ts | 7 ++++++- ...d-agent-session-view-start-after-failed-start.test.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 73ae278f67d5..ee2324507547 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -24,6 +24,11 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => unknown): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + const relaunchedRoots: string[] = [] afterEach(async () => { @@ -91,7 +96,7 @@ it('settles a turn a dead generation left running when a send opens the chat and await expect(host.send(CALLER, { envelope: sendEnvelope, body })).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => + await eventually(() => expect( host .journalSnapshot(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index aac234cd52bb..6b50dfa797ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -24,6 +24,11 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => unknown): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + const CALLER = { callerKey: 'client-1' } const SURFACE = 'desktop-chat:1' const LAUNCH_FAILURE = @@ -82,7 +87,7 @@ afterEach(async () => { /** Waits until the adapter has published every exit it saw and the host settled each one. */ async function settleExits(): Promise { - await vi.waitFor(async () => { + await eventually(async () => { await adapter.drainObservedExits() await Promise.all(lifecycle) expect(host.journalSnapshot(SESSION).items.length).toBeGreaterThan(0) @@ -144,7 +149,7 @@ describe('a fresh chat whose Claude start fails', () => { expect(claude.connections).toHaveLength(1) const sent = await send('reply with exactly: alpha') - await vi.waitFor(() => + await eventually(() => expect( host.journalSnapshot(SESSION).submissions.find((s) => s.clientMessageId === sent) ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) From 8f66a25289af92159dd8dc57623e309c384dd853 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:02:40 -0700 Subject: [PATCH 60/64] test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. --- ...sion-view-start-after-failed-start.test.ts | 90 +++++++++++-------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 6b50dfa797ed..7fedea124779 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -64,7 +64,7 @@ beforeEach(async () => { lifecycle.push(host.handleAdapterEvent(mapped)) } }, - openConnection: claude.openConnection, + openConnection: (launch, handlers) => claude.openConnection(launch, handlers), readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) @@ -128,42 +128,58 @@ async function send(text: string): Promise { } describe('a fresh chat whose Claude start fails', () => { - it('shows one row for the start a send made, and a view never starts it again', async () => { - await expect( - host.attach( - CALLER, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } - }) - ) - ).resolves.toMatchObject({ ok: true }) - await settleExits() - const startFailure = `The provider stopped before it finished starting: ${LAUNCH_FAILURE}.` - expect(timeline()).toEqual([startFailure]) - - // The chat's view binds after the create's CLI already died. - await host.hold(SESSION, SURFACE) - expect(claude.connections).toHaveLength(1) + // QA saw three failed starts from opening the chat alone: the create's, and the view's. + it.each([ + ['after the create already died', 20], + ['while the create is still starting', 300] + ] as const)( + 'starts once for the open and once for a send, one row each, when the view binds %s', + async (_when, initDelayMs) => { + claude = fakeClaude({ initDelayMs, exitBeforeInit: LAUNCH_FAILURE }) + await expect( + host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + ).resolves.toMatchObject({ ok: true }) + if (initDelayMs === 20) { + await settleExits() + } + // Two surfaces bind, as a pane and a second window do; exit recovery sees a holder. + await host.hold(SESSION, SURFACE) + await host.hold(SESSION, 'desktop-chat:2') + if (initDelayMs === 300) { + // The views bound to the create's child itself, before it exited. + expect(timeline()).toEqual([]) + } + await settleExits() + const startFailure = `The provider stopped before it finished starting: ${LAUNCH_FAILURE}.` + // Opening the chat: the create's start, once, and its row. + expect(claude.connections).toHaveLength(1) + expect(timeline()).toEqual([startFailure]) - const sent = await send('reply with exactly: alpha') - await eventually(() => - expect( - host.journalSnapshot(SESSION).submissions.find((s) => s.clientMessageId === sent) - ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) - ) - await settleExits() - // The send's own start, once, and one row for it below the message. - expect(claude.connections).toHaveLength(2) - expect(timeline()).toEqual([startFailure, 'message', startFailure]) + const sent = await send('reply with exactly: alpha') + await eventually(() => + expect( + host.journalSnapshot(SESSION).submissions.find((s) => s.clientMessageId === sent) + ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) + ) + await settleExits() + // The send's own start, once, and one row for it below the message. + expect(claude.connections).toHaveLength(2) + expect(timeline()).toEqual([startFailure, 'message', startFailure]) - // Switching away and back re-takes the view's hold; it starts nothing and adds no row. - host.release(SESSION, SURFACE) - await host.hold(SESSION, SURFACE) - await settleExits() - expect(claude.connections).toHaveLength(2) - expect(timeline()).toEqual([startFailure, 'message', startFailure]) - }) + // Switching away and back re-takes the view's hold; it starts nothing and adds no row. + host.release(SESSION, SURFACE) + await host.hold(SESSION, SURFACE) + await settleExits() + expect(claude.connections).toHaveLength(2) + expect(timeline()).toEqual([startFailure, 'message', startFailure]) + } + ) }) From ad04c3be461f555c14e0ab302f42e1ef323a0991 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:26:28 -0700 Subject: [PATCH 61/64] fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. --- ...structured-agent-session-attach-context.ts | 6 +- ...ured-agent-session-attach-orchestration.ts | 4 +- ...-agent-session-attach-test-conversation.ts | 7 +- ...uctured-agent-session-conversation-open.ts | 18 +++-- .../structured-agent-session-host-delivery.ts | 12 ++- ...agent-session-send-open-stale-turn.test.ts | 76 ++++++++++++------- 6 files changed, 80 insertions(+), 43 deletions(-) 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..d6e5e9097a22 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 @@ -15,6 +15,7 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import type { StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' export type StructuredAgentSessionAttachContext = { deps: StructuredAgentSessionHostDeps @@ -42,5 +43,8 @@ export type StructuredAgentSessionAttachContext = { forgetStatus: (sessionId: string) => void publishStatus?: (sessionId: string) => void /** The conversation's one open journal, opened when closed; see `conversation-open`. */ - openConversation: (sessionId: string) => Promise + openConversation: ( + sessionId: string, + options?: StructuredAgentSessionConversationOpenOptions + ) => 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 97f55563b4f5..591c09d8e32b 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 @@ -160,7 +160,9 @@ async function runAttach( now: () => context.now(), recordPhase, openConversation: async (record) => { - const conversation = await context.openConversation(record.sessionId) + const conversation = await context.openConversation(record.sessionId, { + acquisition: true + }) if (!conversation) { throw new Error('agent_session_identity_required') } 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 index 4b8037077aa8..1e2322cbc6d8 100644 --- 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 @@ -10,6 +10,9 @@ export function openTestAttachConversation( adapter: Pick = {} ): (record: AgentSessionRecord) => Promise { return async (record) => - (await openStructuredAgentSessionConversationJournal({ journalRoot, adapter }, record)).session - .journal + ( + await openStructuredAgentSessionConversationJournal({ journalRoot, adapter }, record, { + acquisition: true + }) + ).session.journal } 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 19d049039878..07d1fb6b5ffb 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 @@ -39,6 +39,10 @@ export type StructuredAgentSessionConversationOpenDeps = { onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] } +/** An acquisition's own open: its reserve cleared the record's death evidence, so it settles + * what the gone generation left running itself, from what it read before. */ +export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean } + export type StructuredAgentSessionConversationOpenContext = { deps: StructuredAgentSessionConversationOpenDeps sessions: Map @@ -53,7 +57,8 @@ export type StructuredAgentSessionConversationOpenContext = { * session's serialize, which is what makes "not open yet" exact. */ export async function openStructuredAgentSessionConversation( context: StructuredAgentSessionConversationOpenContext, - sessionId: string + sessionId: string, + options: StructuredAgentSessionConversationOpenOptions = {} ): Promise { const open = context.sessions.get(sessionId) if (open) { @@ -63,7 +68,7 @@ export async function openStructuredAgentSessionConversation( if (!record) { return null } - const opened = await openStructuredAgentSessionConversationJournal(context.deps, record) + const opened = await openStructuredAgentSessionConversationJournal(context.deps, record, options) await context.adoptOpened(sessionId, opened) return opened.session } @@ -71,7 +76,8 @@ export async function openStructuredAgentSessionConversation( /** The open itself, indexed by nobody yet: the caller adopts the result. */ export async function openStructuredAgentSessionConversationJournal( deps: Omit, - record: AgentSessionRecord + record: AgentSessionRecord, + options: StructuredAgentSessionConversationOpenOptions = {} ): Promise { const { sessionId } = record const fence = record.lease.runtimeFence @@ -98,9 +104,9 @@ export async function openStructuredAgentSessionConversationJournal( } try { // No child in this process writes to a journal nobody had open, so whatever it shows running - // belongs to a generation that is gone. Settled before any reader or child sees it — unless an - // acquisition holds the lease: it cleared the evidence, and settles from what it read before. - if (record.lease.claimStatus !== 'reserved' && record.lease.claimStatus !== 'live') { + // belongs to a generation that is gone, whatever the lease still claims. Settled before any + // reader or child sees it. + if (!options.acquisition) { await settleStaleStructuredAgentSessionState({ journal: opened.journal, sessionId, 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..dab9ea639c67 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 @@ -7,7 +7,8 @@ import type { AgentJournalResetReason } from '../../../shared/agent-session-jour import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openStructuredAgentSessionConversation, - type OpenedStructuredAgentSessionConversation + type OpenedStructuredAgentSessionConversation, + type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' @@ -23,7 +24,10 @@ import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop /** For a caller inside the session's serialize. */ - open: (sessionId: string) => Promise + open: ( + sessionId: string, + options?: StructuredAgentSessionConversationOpenOptions + ) => Promise /** Indexes a conversation some other open produced, as `open` would have. */ adoptOpened: ( sessionId: string, @@ -71,8 +75,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { return { loop, adoptOpened, - open: (sessionId) => - openStructuredAgentSessionConversation({ deps, sessions, adoptOpened }, sessionId) + open: (sessionId, options) => + openStructuredAgentSessionConversation({ deps, sessions, adoptOpened }, sessionId, options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index ee2324507547..ffd06ae4dace 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -6,6 +6,7 @@ import { cp, rm } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -38,7 +39,7 @@ afterEach(async () => { }) /** A host that dies mid-turn, relaunched over a copy of its files taken at the crash. */ -async function relaunchAfterCrashMidTurn() { +async function relaunchAfterCrashMidTurn(probe: AgentSessionOwnerProbe) { const dying = hostTestState() await attach() const events = dying.acquire.mock.calls[0]?.[0].events @@ -72,7 +73,7 @@ async function relaunchAfterCrashMidTurn() { journalRoot: relaunched, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner: async () => probe, now: () => NOW }) await host.reconcileRestartLeases() @@ -80,40 +81,57 @@ async function relaunchAfterCrashMidTurn() { return { host, acquire } } -function runningTurns(host: StructuredAgentSessionHost) { +/** Neither proof of the old owner's death is an observed exit, so the turn ends `unverifiable`. */ +function turnStates(host: StructuredAgentSessionHost) { return host .journalSnapshot(SESSION) .items.flatMap((item) => readAgentJournalTurn(item.body) ?? []) - .filter((turn) => turn.state === 'running') + .map((turn) => turn.state) } -it('settles a turn a dead generation left running when a send opens the chat and its start fails', async () => { - const { host, acquire } = await relaunchAfterCrashMidTurn() - expect(host.hasSession(SESSION)).toBe(false) +// A host that cannot prove the old owner gone leaves its lease in recovery, still claimed, until +// the next acquire resolves it: the open is not that acquire, and the turn is no less gone. +const PROBES = [ + ['the old owner is proven gone', { outcome: 'pid-absent' }], + [ + 'nothing proves the old owner gone', + { outcome: 'indeterminate', reason: 'This host cannot probe structured session owners.' } + ] +] as const satisfies readonly (readonly [string, AgentSessionOwnerProbe])[] - const body = hostTestMessage('sent to a chat nobody has open') - const sendEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: sendEnvelope, body })).resolves.toMatchObject({ - ok: true - }) - await eventually(() => - expect( - host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === sendEnvelope.clientOperationId) - ).toMatchObject({ dispatchState: 'rejected' }) - ) +it.each(PROBES)( + 'settles a turn a dead generation left running when a send opens the chat and its start fails, when %s', + async (_when, probe) => { + const { host, acquire } = await relaunchAfterCrashMidTurn(probe) + expect(host.hasSession(SESSION)).toBe(false) - expect(acquire).toHaveBeenCalledOnce() - expect(runningTurns(host)).toEqual([]) - await host.flushAllStreamedEvents() -}) + const body = hostTestMessage('sent to a chat nobody has open') + const sendEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: sendEnvelope, body })).resolves.toMatchObject({ + ok: true + }) + await eventually(() => + expect( + host + .journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === sendEnvelope.clientOperationId) + ).toMatchObject({ dispatchState: 'rejected' }) + ) -it('settles the same turn when a reader opens the chat', async () => { - const { host } = await relaunchAfterCrashMidTurn() + expect(acquire).toHaveBeenCalledOnce() + expect(turnStates(host)).toEqual(['unverifiable']) + await host.flushAllStreamedEvents() + } +) - await host.revealSession(SESSION) +it.each(PROBES)( + 'settles the same turn when a reader opens the chat, when %s', + async (_when, probe) => { + const { host } = await relaunchAfterCrashMidTurn(probe) - expect(runningTurns(host)).toEqual([]) - await host.flushAllStreamedEvents() -}) + await host.revealSession(SESSION) + + expect(turnStates(host)).toEqual(['unverifiable']) + await host.flushAllStreamedEvents() + } +) From 05b8b9307b6e576a9fe3f804580ce88ca9dc8b2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:32:48 -0700 Subject: [PATCH 62/64] test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. --- ...sion-view-start-after-failed-start.test.ts | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 7fedea124779..b8e2d73c27ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -40,11 +40,14 @@ let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let claude: ReturnType let lifecycle: Promise[] +/** Every initialize waits on it: a start that must outlast a step does not race a timer. */ +let initGate: Promise beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-view-start-after-failed-start-')) resetHostTestOperationIds() lifecycle = [] + initGate = Promise.resolve() // Every start spawns, is published, and exits before it answers initialize. claude = fakeClaude({ initDelayMs: 20, exitBeforeInit: LAUNCH_FAILURE }) adapter = new ClaudeStructuredSessionAdapter({ @@ -64,7 +67,16 @@ beforeEach(async () => { lifecycle.push(host.handleAdapterEvent(mapped)) } }, - openConnection: (launch, handlers) => claude.openConnection(launch, handlers), + openConnection: async (launch, handlers) => { + const connection = await claude.openConnection(launch, handlers) + const initialize = connection.initializationResult + return Object.assign(connection, { + initializationResult: async () => { + await initGate + return initialize() + } + }) + }, readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) @@ -130,12 +142,21 @@ async function send(text: string): Promise { describe('a fresh chat whose Claude start fails', () => { // QA saw three failed starts from opening the chat alone: the create's, and the view's. it.each([ - ['after the create already died', 20], - ['while the create is still starting', 300] + ['after the create already died', false], + ['while the create is still starting', true] ] as const)( 'starts once for the open and once for a send, one row each, when the view binds %s', - async (_when, initDelayMs) => { - claude = fakeClaude({ initDelayMs, exitBeforeInit: LAUNCH_FAILURE }) + async (_when, createStillStarting) => { + // Released only once the views bound, so no runner is slow enough to let the create die first. + let releaseCreate = (): void => {} + const createGate = new Promise((resolve) => { + releaseCreate = resolve + }) + if (!createStillStarting) { + releaseCreate() + } + initGate = createGate + claude = fakeClaude({ exitBeforeInit: LAUNCH_FAILURE }) await expect( host.attach( CALLER, @@ -147,15 +168,16 @@ describe('a fresh chat whose Claude start fails', () => { }) ) ).resolves.toMatchObject({ ok: true }) - if (initDelayMs === 20) { + if (!createStillStarting) { await settleExits() } // Two surfaces bind, as a pane and a second window do; exit recovery sees a holder. await host.hold(SESSION, SURFACE) await host.hold(SESSION, 'desktop-chat:2') - if (initDelayMs === 300) { + if (createStillStarting) { // The views bound to the create's child itself, before it exited. expect(timeline()).toEqual([]) + releaseCreate() } await settleExits() const startFailure = `The provider stopped before it finished starting: ${LAUNCH_FAILURE}.` From 7002b1bfb717c6b006f16c8f67891802f23b7cee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:17:23 -0700 Subject: [PATCH 63/64] refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. --- ...se-native-chat-structured-composer-send.ts | 5 ++-- src/shared/agent-session-error-text.test.ts | 23 ------------------- src/shared/agent-session-error-text.ts | 22 ------------------ 3 files changed, 3 insertions(+), 47 deletions(-) delete mode 100644 src/shared/agent-session-error-text.test.ts delete mode 100644 src/shared/agent-session-error-text.ts 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 2db9fd248901..f208e6cb4a93 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,7 +5,6 @@ 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' @@ -84,7 +83,9 @@ export function useNativeChatStructuredComposerSend({ clearSkillOrigin() clearImageAttachments() }) - .catch((error) => structuredTransport.onError(agentSessionErrorText(error))) + .catch((error) => + structuredTransport.onError(error instanceof Error ? error.message : String(error)) + ) }, [ agent, diff --git a/src/shared/agent-session-error-text.test.ts b/src/shared/agent-session-error-text.test.ts deleted file mode 100644 index 91fc1899d1d8..000000000000 --- a/src/shared/agent-session-error-text.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 9709cd30a68f..000000000000 --- a/src/shared/agent-session-error-text.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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 -} From cb5ce82ab7a12717d48792f549238deb9f925c83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:17:25 -0700 Subject: [PATCH 64/64] test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. --- .../use-structured-agent-session-outbox-rejection-cause.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 334d4ef18b66..a8b9f01b20b9 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 @@ -264,6 +264,7 @@ describe('a send the host rejected because the agent never started', () => { ) await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + expect(shownFailure(result.current.outbox[0])).toBe(reason) act(() => expect(result.current.send('second')).toBe(true)) await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) })