From c867bd2c30d0c21be8365df2a56200d8794d0e57 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:28:09 -0700 Subject: [PATCH 001/122] 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 e897af7387c..7b102bdf419 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 30fb4c44ae2..70f8a8f0d3d 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 48b44e78ff5..2ce48ff908d 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 b740d89e335..b2efed691ee 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 00000000000..17067da8747 --- /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 00000000000..c845703017b --- /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 6e1140b0f4d..00000000000 --- 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 3772e9c5e52..00000000000 --- 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 f907d1fcb4e..00000000000 --- 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 8c09335fd9d..00000000000 --- 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 6d2e99d4b00..00000000000 --- 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 f352423116e..00000000000 --- 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 ff525f64023..00000000000 --- 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 a46e01551ee..00000000000 --- 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 1c6839bf867..00000000000 --- 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 cada4249591..00000000000 --- 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 2de66c0dc71..130d167f21e 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 0e9293f1616..dfab50cae08 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 633f4c91b4c..a5cb3ac5ff8 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 4bcc4f4e643..c638743116a 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 ef1cab100f8..4a8bdd7c0a0 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 6dfacbd2ff0..5d922d72350 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 df0fabf22fa..5b56db5552e 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 705baf182fa..b9b7021e854 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 1255cc85ff3..00000000000 --- 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 f8ba8907216..ab22f5c41d8 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 b881d55e771..00000000000 --- 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 02bf6259e20..00000000000 --- 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 286a0dca059..00000000000 --- 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 4259fc61725..00000000000 --- 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 b9559868f4b..00000000000 --- 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 7c77806ad91..00000000000 --- 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 da8d2eaad3d..00000000000 --- 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 420129792c9..00000000000 --- 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 567b4b7c256..00000000000 --- 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 a947b5e8ecc..00000000000 --- 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 9eb394734eb..00000000000 --- 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 f8d6db2bace..00000000000 --- 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 9ea3d7ff08f..00000000000 --- 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 0aab4f0335b..00000000000 --- 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 3bf0bc08e8c..00000000000 --- 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 a6ad92d91ee..00000000000 --- 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 fe480d6359c..00000000000 --- 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 66005959378..00000000000 --- 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 91c6163bd17..00000000000 --- 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 1dcd4904895..00000000000 --- 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 2917fca2fe2..00000000000 --- 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 7d519a558b5..00000000000 --- 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 81ef23aeb3b..00000000000 --- 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 ca33e486999..00000000000 --- 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 550ebded0da..00000000000 --- 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 c1115f33a2b..00000000000 --- 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 3c6277601af..00000000000 --- 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 57333d1c90c..00000000000 --- 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 f1dc020340e..00000000000 --- 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 55e186ba523..00000000000 --- 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 41e2d8c61e3..00000000000 --- 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 90d60dc501d..8d54ef52784 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 2b0042bee98..39fe7c3fc22 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 00000000000..d04e17d2d26 --- /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 cc49ec3a762..6b1693c3dca 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 468ec57f012..2ff77d4436d 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 7426bcfba2e..b980c4e422c 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 533c86a61ce..8f29c6ec322 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 f0540b2b225..f8323d40fe1 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 e31ea8c95a7..f1e5ca98305 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 00000000000..d4def4833a8 --- /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 3a8de86c3de..00000000000 --- 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 91be1a15aa5..00000000000 --- 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 c88f0918d25..00000000000 --- 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 5b4f0556915..7957ed326a8 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 00000000000..3e11156b883 --- /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 aed78f3e5b9..00000000000 --- 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 543a1a7be70..65b913daf42 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 1bb03e95b2e..5d579fbe256 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 a0099be32a7..a11c3a85b6c 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 4f83a1855f3..d6e4e341588 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 fe545c29447..adf142ddb73 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 97f5321e807..566156e096f 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 d6a4a4397e4..f46ff48ce15 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 8cc202b28c7..38c04d2a597 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 0baf6fe9952..1b054018371 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 8d99ea9098f..291a6e0c53e 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 8676e86acfb..bc36df76df8 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 a467a318c80..f48af3b4d4b 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 120eeaeb635..1000881220e 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 e982dcb5d10..82f8c551642 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 ce5fb03c246..af5e231730c 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 2071b74ed45..d1b17ef49b5 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 19f0c294180..00000000000 --- 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 fcd5625a48a..585cbd4fbbd 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 704f05fad60..00000000000 --- 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 9866b811c61..00000000000 --- 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 10ce2416a32..00000000000 --- 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 fa29002c934..00000000000 --- 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 6a9080d4a9e..00000000000 --- 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 71f9576f368..00000000000 --- 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 fdf18e4f9df..00000000000 --- 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 ffc97c9efa1..1ef945b1ea2 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 2d668a6ab2b..0f7d7c7367f 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 5bd232c3041..00000000000 --- 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 2987d06e21c..00000000000 --- 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 dd8e25531b7..00000000000 --- 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 28617817647..92c0ae27465 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 94ad79c051b..7c339131a6b 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 8fb078eb3b9..662016ad6ef 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 208eb7431b4..d3525de2de9 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 0a4e3c39a54..00000000000 --- 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 3f78fa2e456..00000000000 --- 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 a50fd4cd94e..45c59e02744 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 db4d0b07b8d..00000000000 --- 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 dc276726dc6..00000000000 --- 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 e840da4944b..6f5a8bd8198 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 73669b5a0c3..a29b3b9d0e0 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 81d4dc94dbb..c366b6e57ef 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 76d67061f4d..a15be97f8a4 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 f73c2265eba..f2143b9772c 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 3207199f83e..cf7e8e56db1 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 12e19f2b471..00000000000 --- 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 f641b7bb036..d53b465d97d 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 3e2cab8d726..00000000000 --- 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 89836d1e027..00000000000 --- 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 dcd53edf964..00000000000 --- 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 fe981ff8121..00000000000 --- 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 d15f754b244..00000000000 --- 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 afed92ec8aa..cef9e7dd8c1 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 0ee8bae1ae1..72c52c888fd 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 60b28425057..95e3e3d6eee 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 9f698a44741..a9cf5cd7957 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 4425e4b883c..9daf97d55af 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 b817101d968..2d2760832ad 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 e2e37593172..66d44d4b782 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 4f5b1ca4bee..dfb2660024c 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 12b1466cacf..bb23548ae6f 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 36e917ebc7d..5ee2cc8e7fc 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 8bdac82abbe..2bc7337fe3f 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 e6656c025a8..00000000000 --- 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 9656e7085f1..aad5bcf6257 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 3652aae1880..00000000000 --- 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 c9c733f4ef2..00000000000 --- 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 7177cb9bfc3..00000000000 --- 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 d91518cdd4c..00000000000 --- 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 fb5919824e5..00000000000 --- 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 f5ee019882b..00000000000 --- 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 39495872b93..00000000000 --- 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 658ae15247c..00000000000 --- 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 4a185a3d630..00000000000 --- 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 840039564d8..00000000000 --- 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 64eb129d091..304168a328a 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 84bd050d09b..d7706913d21 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 b8aad8061cc..70778381c6b 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 bba08bd914c..00000000000 --- 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 683d82c3578..907d011e657 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 0147111e3de..64b98d6b1c9 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 dae80d521e4..5b8e758ca3c 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 0386126ae39..9d1f7e398f8 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 b88637923cd..b8370fcf76e 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 5209d9b134d..a5f543b1479 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 ae650c0e118..12ea8d33eb2 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 b541e363504..165ce1cfa70 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 8800f349802..00000000000 --- 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 74dd3529cf6..733cad72894 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 dd8b5433efd..9f6043834e1 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 18887a115e0..28ba0270740 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 bc7f5761db7..ea99929c618 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 f5e9a1a7735..8763674df1c 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 75ce023b6ef..9777cfd42d1 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 1e8853bd546..e6e193b40dd 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 e907b413263..57d90d2218a 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 e62d5646894..7f41bd2c4b8 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 d3c8cacffb7..17de32fd71b 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 b2855ac1021..a336dea6fed 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 37baf39cb43..2f839f93330 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 1b2bebd5a80..7af9283f328 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 db75fcd5f8f..d8c8b80b329 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -90,11 +90,6 @@ export const STRUCTURED_CALLS: { hostMethod: 'changeThreadGoal', result: { ok: true, replayed: false } }, - { - method: 'agentSession.requestHandoff', - hostMethod: 'requestHandoff', - result: { status: { owner: 'native' } } - }, { method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus', @@ -262,14 +257,6 @@ export function paramsFor(method: string): unknown { const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } return { envelope: envelope({ method, fields, fence }), ...fields } } - case 'agentSession.requestHandoff': { - const fields = { - direction: 'to-tui' as const, - mode: 'now' as const, - action: 'start' as const - } - return { envelope: envelope({ method, fields, fence }), ...fields } - } case 'agentSession.setOption': { const fields = { key: 'model', value: 'gpt-5' } return { envelope: envelope({ method, fields, fence }), ...fields } From eab4ef1edf979be4301413c73a080bb263d82b36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:54:27 -0700 Subject: [PATCH 002/122] 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 ff4e690677e..e565955abcd 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 ab22f5c41d8..951bbd2ab32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -23,6 +23,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string @@ -50,6 +51,9 @@ export type StructuredAgentSessionEvictionContext = { releaseLease: () => Promise } +/** The resume offer is advisory; a stalled sink must not hold the child's stop behind it. */ +const SNAPSHOT_DRAIN_TIMEOUT_MS = 1_000 + export type StructuredAgentSessionEvictionStep = { name: string run: (context: StructuredAgentSessionEvictionContext) => Promise | void @@ -65,7 +69,7 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe return } // Events the provider already delivered are part of what the sidebar showed at the stop. - await context.eventSink.drained() + await withTimeout(context.eventSink.drained(), SNAPSHOT_DRAIN_TIMEOUT_MS, null) try { context.beforeProviderChildStop() } catch { From abafbbcc08ea78724991572196fe01a1ac279a65 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:54:29 -0700 Subject: [PATCH 003/122] 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 b85dd60a854..3f7e1893462 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 42330dd6fe8..106d1baccc4 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 16f01d5fbe7..de24a271712 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 f2c7cf4405d..bf5821215e8 100644 --- a/src/main/runtime/orca-runtime-core.ts +++ b/src/main/runtime/orca-runtime-core.ts @@ -4,7 +4,6 @@ import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-termi import type { ResolvedWorktree } from './runtime-worktree-path-identity' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import { isCursorAgentTitle } from '../../shared/agent-detection' -import { isAbsolute, relative, resolve } from 'node:path' import { IMMEDIATE_KILL_REPLY_BUDGET_MS } from '../daemon/immediate-kill-reply-budget' import type { RuntimeTerminalDriverState, @@ -274,11 +273,6 @@ export function addListenerToMap( } } -export function isPathWithinDirectory(directory: string, candidate: string): boolean { - const relativePath = relative(resolve(directory), resolve(candidate)) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', From 3cbf6b58f467764a9c85208d96d1c9fc2f04fa56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:56:29 -0700 Subject: [PATCH 004/122] 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 bc713f92171..fa4289da396 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 4a8bdd7c0a0..38595d4e6eb 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 c602d3a9bb9..00df8b4e910 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 2ff77d4436d..574ced3cc38 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 09a6248c0b7..29bf29a00f1 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 06740bfab25..5363b8588e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -73,7 +73,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< } const record = context.store.getRecord(unexpectedEvent.sessionId) if (!record || record.lease.handoffStage !== null) { - // The handoff coordinator owns an already-started transition. + // An acquisition or recovery already owns this lease's transition. session.hasProviderChild = false context.publishStatus?.(unexpectedEvent.sessionId) return null From e048579cf5b9e33ce3d2cfa5dc027156a72bfe63 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:57:28 -0700 Subject: [PATCH 005/122] 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 e565955abcd..cb0cb2fb233 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -101,12 +101,7 @@ describe('structured agent session eviction', () => { const snapshot = vi.fn() ctx.beforeProviderChildStop = snapshot // A journal write that never settles, then a healthy sink once the child is stopped. - ctx.eventSink.drained = vi - .fn() - .mockReturnValueOnce(new Promise(() => {})) - .mockResolvedValue({ - ok: true - }) as unknown as StructuredAgentSessionEvictionContext['eventSink']['drained'] + vi.mocked(ctx.eventSink.drained).mockReturnValueOnce(new Promise(() => {})) const eviction = evictStructuredAgentSession(ctx) await vi.advanceTimersByTimeAsync(1_000) From b50c57472efa3b201a888196c6ee2cf04fd500ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:00:25 -0700 Subject: [PATCH 006/122] 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 c6ae4fc2ffa..ebb893ef7e5 100644 --- a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts +++ b/src/main/runtime/agent-session-eviction-settlement-latch.test.ts @@ -30,6 +30,32 @@ describe('proven-dead agent session eviction settlement', () => { }) }) + it('owes no settlement for a start that died before proving its handle', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeKind: 'native', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + handoffOperationId: 'attach-op-1', + unreconciled: true + }) + ) + + const evicted = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + expect(evicted.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 8, + handoffStage: null, + handoffOperationId: null + }) + expect(evicted.lease).not.toHaveProperty('settlementRetryRequired') + }) + it('latches recovery eviction from the same evicted disposition', () => { const record = agentSessionRecordFixture( agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) From 12efdefd1f95e441eaf8a34f5a7807b61e588167 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:02:53 -0700 Subject: [PATCH 007/122] 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 b980c4e422c..0deaf9d9140 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 8f29c6ec322..ba508b67bcf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -271,11 +271,14 @@ export class StructuredAgentSessionHost { async handoffStatus(sessionId: string): Promise { this.requireSession(sessionId) - const record = this.deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return structuredAgentSessionOwnerStatus(record) + // Queued behind an in-flight attach, so a starting chat answers with its settled owner. + return this.serialize(sessionId, async () => { + const record = this.deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return structuredAgentSessionOwnerStatus(record) + }) } history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => From 42d10eee156f67334757110be850e3c35740d0c8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:22:41 -0700 Subject: [PATCH 008/122] 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 ce23d4221ca..b9b13d30304 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 90dbc7fd459..06df95f55a9 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 06d897828fe..97e117b2229 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 091d5eb4474..00000000000 --- 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 488e58825e6..00000000000 --- 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 5dcb14f4516..ceeb5127b7d 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 7e42342040d..c7348ddfaef 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 a5cb3ac5ff8..8512bc7c16b 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 c638743116a..703fa009b7d 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 a7a86662809..ee6e7a5b9d0 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 10dd2b87e56..266b819dbae 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 34a99447277..00000000000 --- 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 7c339131a6b..00000000000 --- 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 662016ad6ef..00000000000 --- 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 68ebabb8857..f433c75793d 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 679728ca1f0..56721971132 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 27012bdf8e9..56455551349 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 659ee439294..588a834d6e6 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 efd0f5cb1c9..ad7c81f2e87 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 b3eeeee2965..538ab4d6d4d 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 b5aff5f755c..5e90602755c 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 117144823cc..00000000000 --- 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 c4b528d1d9b..00000000000 --- 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 6eb8398cc60..00000000000 --- 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 e819a4a0886..8dbedc9b33c 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 a181b89aebc..048fc7732e4 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 3801bae5959..62a555d4119 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 09e5010a104..e2f8b4f6b24 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 dd8ccf64f71..4071d17a073 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 86de5481297..8f1f6035dd8 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 f2dee00b44c..f91ff690922 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 8c234ce2ba4..7313eb82b94 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 c62c70c5905..99c01d902a4 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 94c6731f384..00000000000 --- 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 9a139efc49c..7ff6ba4a7f3 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 aad5bcf6257..91595eb124f 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 f55a802e979..07a2b671f0a 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 009b9048d72..b2d0891eac6 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 9718fc8d5dc..00000000000 --- 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 78f9b915f69..00000000000 --- 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 6820b6ce44b..00000000000 --- 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 057098d2947..e3215e4873d 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 57560caa7a5..235ef1e6b79 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,4 +1,3 @@ -import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -210,11 +209,6 @@ export type RuntimeTerminalSend = { accepted: boolean bytesWritten: number refusedReason?: 'no-agent' | 'permission' - /** - * Present only when a durable agent-session lease refused the write. Additive and optional: an - * old client sees the `accepted: false` it already handles and ignores this field. - */ - agentSessionRefusal?: AgentSessionPtyWriteRefusal prompt?: RuntimeTerminalPromptDelivery } From 048391506f6f7d3dea8a0a8a25d6eb4220e9125b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:27:15 -0700 Subject: [PATCH 009/122] 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 38641043b70..440fad3a3fc 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 2fa2c1349bd..ce283ce501d 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 00000000000..befb320b9e5 --- /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 13673626ebc..259f552dd98 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 ed509190038..cf9ff45805c 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 1ef945b1ea2..4865df90af9 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 2c965b932d5..3f8adae4906 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -6,7 +6,7 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,7 +31,7 @@ import type { import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' -import { appendLegacyTranscriptMessages } from '../native-chat/agent-session-journal/journal-legacy-import' +import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import type { OrcaRuntimeService } from './orca-runtime' @@ -407,20 +407,21 @@ describe('a structured codex session over agentSession.*', () => { identity, journalDir: journalDirectoryFor(root, identity) }) - await appendLegacyTranscriptMessages({ + const rollout = join(root, 'legacy-rollout.jsonl') + await writeFile( + rollout, + `${JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'legacy question', kind: 'plain' } + })}\n` + ) + await importLegacyTranscriptIntoJournal({ journal, agent: 'codex', sessionId: THREAD, fence: 0, - messages: [ - { - id: 'legacy-user-1', - role: 'user', - source: 'transcript', - timestamp: 1_800_000_000_000, - blocks: [{ type: 'text', text: 'legacy question' }] - } - ] + options: { filePath: rollout } }) const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( From d0f4dae35091b862386ed5ee9cf74f86a39d286a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:28:09 -0700 Subject: [PATCH 010/122] 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 f488e14e31e..e929d628de5 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 ea99929c618..97c3d10b0ae 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -146,7 +146,10 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR if (lease.handoffStage !== null) { return { code: 'agent_session_conflict', - message: `The session is mid-handoff (${lease.handoffStage}).` + message: + lease.handoffStage === 'new-owner-proving' + ? 'The chat is still starting.' + : "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." } } return { From faececed4879b5bf4b54799c04ea87dceadfde4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:29:21 -0700 Subject: [PATCH 011/122] 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 259f552dd98..92b085e2e0f 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -20,7 +20,9 @@ import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' // No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. -const decodedClaudeOverride = vi.hoisted(() => ({ message: null as NativeChatMessage | null })) +const decodedClaudeOverride = vi.hoisted((): { message: NativeChatMessage | null } => ({ + message: null +})) vi.mock('../transcript-line-decoders', async (importOriginal) => { const actual = await importOriginal() From 250250e59de4963f5d22e9e0fa0fc68ede010064 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:02:14 -0700 Subject: [PATCH 012/122] 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 130d167f21e..d4bc35a03ff 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 dfab50cae08..248d2892e32 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 2b0b169b031..8a3fe002cb1 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 34a4006ec45..06f99c594fb 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -177,6 +177,5 @@ describe('mobile RPC allowlist', () => { 'agentSession.release' ]) expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false) - expect(mobileRpcAllowlist().has('agentSession.requestHandoff')).toBe(false) }) }) From b58818524d7eea4114b958ecd8b429ffcbee504c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:11:58 -0700 Subject: [PATCH 013/122] 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 e6e193b40dd..46864533315 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -35,26 +35,14 @@ import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' /** `agentSession.handoffStatus`. Named for the removed terminal handoff; released desktop clients - * still read `owner`, and older hosts can still send the terminal-era fields. */ + * still read `owner`. Clients parse the reply as unknown, since older hosts sent more fields. */ export type AgentSessionHandoffStatus = { owner: AgentSessionOwnerRuntimeKind | 'none' - direction: 'to-tui' | 'to-native' | null - phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed' + direction: 'to-native' | null + phase: 'idle' | 'switching' | 'failed' stage: AgentSessionHandoffStage | null operationId: string | null - hostLabel?: string - terminal?: { - handle: string - tabId: string - paneKey: string - ptyId?: string - } - error?: { - message: string - details?: string - recoverableOwner: AgentSessionOwnerRuntimeKind | 'none' - canRetryProof?: boolean - } + error?: { message: string; recoverableOwner: 'none' } } export type { From 35742fd3c0b6b6532d03eda65e9804a7bef30024 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:56:10 -0700 Subject: [PATCH 014/122] 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 0deaf9d9140..62f88672342 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 d4def4833a8..42ffe172cd7 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 3e11156b883..890b8f34fee 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 d6e4e341588..105521bbf98 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 adf142ddb73..40317552d6e 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 e864a1bc22b..4ba1a93169b 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 1305c23313b..21a5fe4e49d 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 c790a149f31..9335dd230bb 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 0ea430774aa..02866a49fb3 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 00000000000..906921645d4 --- /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 ddfaa163d73..7a61ea7bdfa 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 3c5c55d83c6..084cc93ac4d 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 303bb8162d4..b721055e2a9 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 00000000000..997e32fb7cb --- /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 10566e4eb20..58817a7e59c 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 3d1f6d33954..a6c90d1e01f 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 e5b36cdf67f..4fff2d387d0 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 984dad9996f..a0d0426509b 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 2dbe9dabd0d..c11119878f6 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 670859444c9..98989a85891 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 b29d68c297a..08245281f62 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 d6a1caee8e6..c426132b848 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 709bdbc8db4..20930ace637 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 8d7f4ef6b70..802b23733bc 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 9f6043834e1..ff1020ece74 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 28ba0270740..6c1d07820ad 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 00000000000..b38c411fc6d --- /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 00000000000..af2216d0ab3 --- /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 97c3d10b0ae..83fdb65e9ae 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 65da67c571e..3e2270d368b 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 8763674df1c..6cfe07e70b4 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 46864533315..38849ab0f05 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -28,7 +28,6 @@ import { agentSessionScopeKey, type AgentSessionExecutionLocation, type AgentSessionHandoffStage, - type AgentSessionOwnerRuntimeKind, type AgentSessionRecord } from './agent-session-record' import type { AgentProviderSessionMetadata } from './agent-session-resume' @@ -37,7 +36,7 @@ import type { StructuredAgentSessionProjectedStatus } from './structured-agent-s /** `agentSession.handoffStatus`. Named for the removed terminal handoff; released desktop clients * still read `owner`. Clients parse the reply as unknown, since older hosts sent more fields. */ export type AgentSessionHandoffStatus = { - owner: AgentSessionOwnerRuntimeKind | 'none' + owner: 'native' | 'none' direction: 'to-native' | null phase: 'idle' | 'switching' | 'failed' stage: AgentSessionHandoffStage | null From 54ce5fca0d2e2b119642a2e71c2fc37078f062a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:58:00 -0700 Subject: [PATCH 015/122] 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 38595d4e6eb..2e42ffe9893 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 d83ca49a79c..c449711e745 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 f8323d40fe1..f969e3c0bbf 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 105521bbf98..b647ed01883 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 02866a49fb3..3c6ac2dcecf 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 ce5d3fd2621..7310c3fff89 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 97b0f95f2b6..51b2fe64daf 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 c95705f2aa5..50cf9e6b35c 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 0c1c88db003..b2788f81602 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 92c0ae27465..580fbeda255 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 5795763d96c..5ba7c41cffc 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 997e32fb7cb..77d94766212 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 f70145cf5a2..63ee368f873 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 20ee4b402fd..f9603e7a86b 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 d3525de2de9..65d689503c2 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 1f807870682..dd0a9dcd28d 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 30d54a108c9..83e9c610056 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 d7a84f6ef69..4bd3bf9b4db 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 0d44d88d82b..cde4091d7eb 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 ec2a1e82e92..50d27baa57a 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -26,7 +26,6 @@ function reserveRequest( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', expectedFence: null, spawnToken: `spawn-${operations}`, claimKeyId: 'key-1', From 713b126feb57dece532ce6c3107d1d1d014dbd6b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:01:14 -0700 Subject: [PATCH 016/122] 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 77d94766212..f0d13ba9794 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -75,6 +75,8 @@ async function persistLiveOwner(): Promise { }, now: NOW }) + // Commits the visibility index, so a later hide changes nothing but a pending rewrite. + await store.setSessionTabVisibility(SESSION, false) } describe('a lease the removed terminal handoff wrote', () => { From 44d797728c7a4c6e7d87f3fbfe0abef2d748471e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:01:50 -0700 Subject: [PATCH 017/122] 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 d72f93770c5..b2bcb3c40f4 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 5a9a6f20801..f240d65273b 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 4b9b46716cb..1464ae5ae6c 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 5482826d072..aba50a33ff4 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 5d922d72350..1fbc73e4afa 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 7681e0f3436..34dc46f23b4 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 b9b7021e854..80c89f48ce2 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 574ced3cc38..5db65f320ac 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 ba508b67bcf..b13a042a85f 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 04001450621..62be8900159 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 566156e096f..87f4f9e3d65 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 19ae18dd5f4..b82248cb06f 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 f48af3b4d4b..e5866241105 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 e4a332f7acf..fa57156d05a 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 f2d1ab12230..638d8a8679f 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 82f8c551642..a5a588559cd 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 af5e231730c..4c188d8c72d 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 585cbd4fbbd..82a7d683a18 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 a261c6dd151..37d8d4ee83d 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 e929d628de5..306450f8a63 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 83fdb65e9ae..fe439c20b08 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 d244fba1ba6..4d18e81ced6 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -726,15 +726,17 @@ describe('cross-version structured agent sessions', () => { expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence) }) - it('refuses a write still fenced to the host generation that died', async () => { + // Every released client still sends the fence it last saw; this host names a write by its + // target and ignores that fence. Only the attach keeps comparing one, which `reattach` pins. + it('delivers a write still fenced to the host generation that died', async () => { const created = await answer('agentSession.create', createIntentParams()) await bootHost('b') const reattached = await reattach(created.fence) expect(reattached.fence).toBeGreaterThan(created.fence) expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } + ok: true, + fence: reattached.fence }) }) }) From 6dad39a1ddef96cc3bb1572302dcc1c510ae28dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:00:47 -0700 Subject: [PATCH 018/122] 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 ec445cb7004..568fbe4d39d 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 00000000000..aa955eeee9c --- /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 00000000000..f76c84d33fc --- /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 00000000000..2f38ddf4da2 --- /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 364f1e7c0dd..906025413a9 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 ba508b67bcf..c6aaa8db662 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 58d104a651c..0dd2337f4ab 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 0770190c2f4..29453e74652 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 4494392fb48..07ec6b81599 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 e4a332f7acf..e277889345f 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 d1b17ef49b5..cb958d8364f 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 4e49e00f587..3f63679d55f 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 6ac29cab0ab..2f6a1a1f1f2 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 bea393eddbb..e8103dac62f 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 fc8440500a4..79f21a349b4 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 022343d9407..5894defc483 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -176,7 +176,6 @@ export function runStructuredConversationCommand( }, { fence: ctx.fence } ) - ctx.publish() try { error = ( await ctx.adapter.compact({ @@ -212,7 +211,6 @@ export function runStructuredConversationCommand( conversationCommand: matching()! } }) - ctx.publish() }) }) ).error @@ -223,7 +221,6 @@ export function runStructuredConversationCommand( { kind: 'status', text: 'Compaction completion is unconfirmed.' }, { fence: ctx.fence } ) - ctx.publish() throw cause } await ctx.journal.appendItem( @@ -231,7 +228,6 @@ export function runStructuredConversationCommand( { kind: 'status', text: error ?? 'Conversation compacted.' }, { fence: ctx.fence } ) - ctx.publish() } const completed = { ...prepared, From 97bf3bb52694f509451d9791d330806cb187d0af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:01:35 -0700 Subject: [PATCH 019/122] 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 f76c84d33fc..f7ec795e2e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -68,6 +68,20 @@ describe('a conversation delivers what its journal commits', () => { expect(deliver).toHaveBeenCalledWith('session-1', journal) }) + it('delivers an epoch replacement, which readers must reload from', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await journal.replaceEpochItems('handle_forked', 0, []) + + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', journal) + }) + it('delivers nothing for a handle the conversation has replaced', async () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ From 427dc8de833b772fd0795585ad7adeddb54f0593 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:16:16 -0700 Subject: [PATCH 020/122] 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 aa955eeee9c..8b8d6fb5ee8 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 f7ec795e2e1..202ffe50ae4 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 574ced3cc38..bf73befc708 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 1c9f271004c..a21405c691a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -242,9 +242,12 @@ describe('site 11: host teardown is failure-complete', () => { const failing = sessions.get(SESSION) const closeError = new Error('close rejected') if (failing) { - failing.journal = { - close: () => Promise.reject(closeError) - } as unknown as AgentSessionJournal + sessions.set(SESSION, { + ...failing, + journal: { + close: () => Promise.reject(closeError) + } as unknown as AgentSessionJournal + }) } await expect( From 2bd44f5f7dcd622f5e0578118ac936b086c1d741 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:22:25 -0700 Subject: [PATCH 021/122] 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 f0d13ba9794..3b92eeb5fe7 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -44,6 +44,8 @@ async function persistLiveOwner(): Promise { }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + // Without one the next open backfills it, and that rewrite would mask the lease's own. + surfaceTabId: 'tab-alpha', expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', From f79eb9b88f97040d6260dd2ec8b5c382267b3500 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:33:29 -0700 Subject: [PATCH 022/122] 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 a5f543b1479..49d13f87f01 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -368,6 +368,40 @@ describe('worktree agent activation gate', () => { }) }) + it.each(['present', 'unknown'] as const)( + 'does not resume OMP when a surfaced %s agent lacks conversation ownership', + async (agentOwnership) => { + const record = { + ...sleepingRecord('old-tab', DEAD_LEAF_ID, 'omp-session'), + agent: 'omp' as const + } + const ptyId = `${WORKTREE_ID}@@current-omp` + const { deps, resume, createTab } = testDeps({ + sessions: [{ ...listed(ptyId), title: 'OMP', agentOwnership }], + sleeping: [record], + surfaceOwners: new Map([ + [ + ptyId, + { + ptyId, + tabId: 'current-tab', + paneKey: `current-tab:${LIVE_LEAF_ID}` + } + ] + ]) + }) + seedExistingSurface(deps.getState(), { + tabId: 'current-tab', + leafId: LIVE_LEAF_ID, + boundPtyId: ptyId + }) + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + expect(resume).not.toHaveBeenCalled() + expect(createTab).not.toHaveBeenCalled() + expect(deps.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record) + } + ) + it('does not use an ambiguous tab binding as a live session claim', async () => { const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session') const livePtyId = `${WORKTREE_ID}@@live-agent` From 50d2e330998efd47424fd198e600f65d7b9c816f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:43:30 -0700 Subject: [PATCH 023/122] 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 8b8d6fb5ee8..c8c908aae70 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 af5e231730c..0b54ea7c430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -15,6 +15,7 @@ import { type AgentSessionSubscribeEvent, type AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import { sameJournalCursor } from '../agent-session-journal/journal-cursor' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { @@ -213,6 +214,11 @@ export class AgentSessionSubscribers { ? this.activityField(subscriber.sessionId).activity : undefined const publishedActivity = activity !== undefined ? activity : checkpointActivity + // Caught up, so there are no rows to read: every publish behind a commit's own delivery. + if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) + return + } const readPage = createAgentSessionCatchUpReader(journal) while (true) { const result = readPage({ @@ -239,20 +245,7 @@ export class AgentSessionSubscribers { const page = result.page const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence if (!advanced) { - const commandsChanged = - this.hooks.readCommands !== undefined && - (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands - if (emitCheckpoint || publishedActivity !== undefined || commandsChanged) { - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: emptyAgentSessionBatch(page.window.nextCursor), - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - } + this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) return } this.emit(subscriber, { @@ -276,6 +269,29 @@ export class AgentSessionSubscribers { } } + private emitCaughtUp( + subscriber: Subscriber, + hostNow: number, + emitCheckpoint: boolean, + backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, + activity: AgentSessionTurnActivity | null | undefined + ): void { + const commandsChanged = + this.hooks.readCommands !== undefined && + (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands + if (emitCheckpoint || activity !== undefined || commandsChanged) { + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: emptyAgentSessionBatch(subscriber.cursor), + fence: subscriber.fence, + hostNow, + ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), + ...(activity !== undefined ? { activity } : {}) + }) + } + } + private now = (): number => this.hooks.now?.() ?? Date.now() private isActive = (subscriber: Subscriber): boolean => From bd72b13662683c3e0bce0830705119a32bd31127 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:48:51 -0700 Subject: [PATCH 024/122] 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 a21405c691a..ef08e1c7e93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -244,6 +244,7 @@ describe('site 11: host teardown is failure-complete', () => { if (failing) { sessions.set(SESSION, { ...failing, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close`, and this map is a plain `Map` that binds no delivery. journal: { close: () => Promise.reject(closeError) } as unknown as AgentSessionJournal From fa9b76a48a1bd0066dcad7861b4b0b9c9bed7cc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:53:11 -0700 Subject: [PATCH 025/122] 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 49dbab1c90d..e7a4165120e 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -132,7 +132,7 @@ function mutationOperation( } } -/** Admit the ledger row and its lease/fence preconditions in one durable transaction. */ +/** Admit the ledger row and its writer-lease precondition in one durable transaction. */ export function admitAgentSessionMutationOperation( state: AgentSessionStoreState, args: AgentSessionMutationOperationAdmission From 5435973e4dc781a3755f0f74819ab55bd6ee690c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:03:34 -0700 Subject: [PATCH 026/122] 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 58d104a651c..85ea6e96171 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 e5866241105..563cb660997 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 181b40660f8..e2e2fb81102 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -166,8 +166,8 @@ describe('a send into a published session whose child ended before startup', () await send('hello again', releasedFence) - // The client was current as of the lost owner, so the send is rebased onto the fence the - // resume published and admitted once, with no stale round trip. + // A send still fenced to the lost owner is admitted once against the restarted one, with no + // stale round trip. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).toHaveBeenCalledOnce() From c753e8ab9aa203a26db40fa8cf1cbdbd1d067709 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:31:08 -0700 Subject: [PATCH 027/122] 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 c9e16084a7d..db84bd432e9 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 d93a8ecab0a..5a5b2796595 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 ec5e5477f45..d95936e9c52 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 e4c9de982d0..9733cd7b2bf 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 4181dab43c7..c117e3e863f 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 b2efed691ee..6728f960ef0 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 ded925bf631..0b8acadf303 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 fb1ab1943b4..ee41dfc628d 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 1d4228ed837..6643bf04c94 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 ef8e0c65469..8ddc29a2e67 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 0df78936191..00000000000 --- 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 00000000000..1bda560377a --- /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 a106c172c45..e81d204f9ca 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 aa1f9a02ecc..d486a670a13 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 fc7e8f8601e..883e5285309 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 023e0ba46b7..c8e3d86b0e8 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 63ba9b1d0b1..0bcfd1bd686 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 c4138ba2241..10f6821fcc5 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 273502c8cf2..3fbee85a417 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 626baa9820a..f37571814bb 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 e17ccf0cdf8..e56ca86a7fb 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 99cb47971e9..0f29f8b3879 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 cea47e381fe..79d86f80dbb 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 ffaea5c5d6f..3637f03dab6 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 568fbe4d39d..5119f7ef597 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 00000000000..efba24208d1 --- /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 7fa1446d6c5..4a8a187becd 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 fb0971b6e90..0c44a31bbe4 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 1464ae5ae6c..7f982718afa 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 3a77aa2d6cc..533f539466c 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 ab9c44949e0..83555d63729 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 aba50a33ff4..26cf103d362 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 97f429933d8..81fb5f92738 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 2e42ffe9893..6b75b159a33 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 b9e8f8e506d..8c99be697c5 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 00000000000..5b20c537cfc --- /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 d553fbaecf3..6d7d8618ad4 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 1b5f73fdbda..aa7d8ea36f3 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 00000000000..7bdc65119c6 --- /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 20531bd6465..304cadf8f86 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 00000000000..3065c464329 --- /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 00df8b4e910..39f0e4415df 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 906025413a9..971c63aa6fc 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 62f88672342..f8591926766 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 d63e1ad8335..ba059204cb1 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 1874f7ee1c6..6bda0b66189 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 300f1423cb3..e7bd9a9ac85 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 42ffe172cd7..3563b506db2 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 62be8900159..24f341d28da 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 d8d2876d272..fadd142a8fa 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 b1eb5704f01..094ddffdcb2 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 baf871aef65..5d5009aa918 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 7957ed326a8..853fa5784a0 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 52699fd062f..bb5d29d4dea 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 34e3fb8a4cf..21c3d9a5f7f 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 65b913daf42..0e1441b729c 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 5d579fbe256..6a105fa0c1d 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 a11c3a85b6c..ed10f63e44c 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 87f4f9e3d65..0d873389890 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 00000000000..8ab9bb250ad --- /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 5dcc3ca7690..97830212b0a 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 1bcb27ca4a2..1d7f0033a15 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 c3301861c01..ca5ba68dee0 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 f46ff48ce15..41ae0a1b1a8 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 38c04d2a597..719c60f3e19 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 07ec6b81599..66461a0c873 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 1b054018371..5f799e4498e 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 291a6e0c53e..c8f75823708 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 bc36df76df8..318f2192cc2 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 b82248cb06f..32b05b08077 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 0f75c9a3322..2494535e019 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 563cb660997..90a30415c14 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 09986f2e129..1fa923e971f 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 e2e2fb81102..211d8684e15 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 b461d508f42..ff9765d1cee 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 6150e3412a6..bc265833c42 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 638d8a8679f..1a6e5606c1c 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 5780359a8ae..8a7a6ad7647 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 00000000000..516122c091c --- /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 df6e6cec4bf..ef612b3a508 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 0fd544ec9ee..c7f61d5c9ae 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 cb958d8364f..c8b8839318f 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 e8103dac62f..9f2cbb0e4b6 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 4ba1a93169b..bba6154af43 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 8eac02dc17b..5a3c2497c34 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 2199da05392..64f1ee652e7 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 78b9f4a59d0..fe6e5501975 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 e7a4165120e..f16d48efb9d 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 19e05811055..da1135f588f 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 0d4b4909637..817b00144bc 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 91bf1158e07..c530a1e84ab 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 b722952df92..907a7bcc097 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 493f9d092c3..7f8fd0fea0d 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 5bde461a059..a889ffb58f1 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 00000000000..9ae846df6fc --- /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 8b948869990..5fde5e0f6e4 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 2d2760832ad..70da8409c37 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 4fa390be0fb..a4e2500ffc4 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 3f8adae4906..d81170ee075 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 7a3736b9009..0b2c8bd4f4b 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 5958e2a8d54..74da4709e93 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 f208e6cb4a9..2db9fd24890 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 d7706913d21..1ee70c57223 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 d66f579ed0d..c1945503d27 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 613a599c2a6..56c4ee3e765 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 e38709b9fed..9801caa80ad 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 00000000000..91fc1899d1d --- /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 00000000000..9709cd30a68 --- /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 29271faa2b1..a978f469547 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 a13f3b1f676..233acf3bbbe 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 fe439c20b08..f0cae00c1b3 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 00000000000..869941d2e12 --- /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 784d7536e02..550e6217e23 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 14e197325d8..a88dad5b16e 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 a336dea6fed..f39b07f6627 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 16765a02471..54168874636 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 36c70a357cb..002db36cb57 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 4d18e81ced6..9a048d09968 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -22,6 +22,7 @@ import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, @@ -582,6 +583,10 @@ describe('cross-version structured agent sessions', () => { let store: AgentSessionRecordStore let runtime: unknown + /** Holds a provider start open, so a reply's timing can be read against it. */ + let startGate: Promise = Promise.resolve() + let starts = 0 + /** Phase 2 owns provider processes; the adapter is the only stub here. */ function adapter(): StructuredAgentSessionAdapter { return { @@ -589,23 +594,27 @@ describe('cross-version structured agent sessions', () => { // `supportsLocation`, which this fake also lacks, so the client-supplied-location gate // refused for the fake's silence rather than for the location. supportsCreate: () => true, - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - // A restarted host re-proves the thread it inherited; only the first - // owner of a session may claim to have created it. - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW + acquire: async ({ fence }) => { + starts += 1 + await startGate + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } } - }), + }, dispatch: async () => ({ state: 'accepted', providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } @@ -660,14 +669,18 @@ describe('cross-version structured agent sessions', () => { return reattached } - async function call(method: string, params: unknown): Promise { + async function call( + method: string, + params: unknown, + clientCapabilities: readonly string[] = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + ): Promise { return callBuild( current, method, params, { clientKind: 'runtime', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + clientCapabilities, clientId: 'paired-device-1', connectionId: 'connection-1' }, @@ -686,6 +699,7 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { resetOperationIds() + startGate = Promise.resolve() root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) runtime = runtimeStub() await bootHost('a') @@ -739,5 +753,58 @@ describe('cross-version structured agent sessions', () => { fence: reattached.fence }) }) + + // A released client answers a send's `pending` as delivered-or-refused; it has no way to show + // a rejection that arrives after it. So it is answered once the message is handed over, while + // a client that advertises accepted sends is answered at acceptance, start or no start (W9). + it('holds the send reply of a released client until the handover, and answers a current one at once', async () => { + const released = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY + ] + expect(baseline.capabilities).not.toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + const created = await answer('agentSession.create', createIntentParams()) + await bootHost('b') + let open = (): void => undefined + startGate = new Promise((resolve) => (open = resolve)) + + let answered = false + const releasedReply = call( + 'agentSession.send', + sendParams('released', created.fence), + released + ).finally(() => (answered = true)) + // The start that delivers it is under way, and the reply still waits for it. + const before = starts + await vi.waitFor(() => expect(starts).toBeGreaterThan(before)) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(answered).toBe(false) + open() + const [reply] = await releasedReply + // Handed over, whatever the provider has said since. + expect(reply).toMatchObject({ + ok: true, + result: { value: { submission: { handedOverAt: expect.any(Number) } } } + }) + + const restarted = await bootHost('c') + startGate = new Promise((resolve) => (open = resolve)) + const currentReply = await call('agentSession.send', sendParams('current', created.fence), [ + ...released, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ]) + expect(currentReply[0]).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'pending', handoverRecorded: true } } } + }) + open() + await vi.waitFor(() => + expect( + restarted + .journalSnapshot(SESSION) + .submissions.every((row) => row.dispatchState !== 'pending' || row.handedOverAt) + ).toBe(true) + ) + }) }) }) From 82f9bfd7b31d8ab73540464efc04e8abaa602f21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:17:17 -0700 Subject: [PATCH 028/122] 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 efba24208d1..dc69482a25b 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 aa7d8ea36f3..0675d8730a6 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 39f0e4415df..e3b284acb5b 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 c0d29f6b9ef..976e9417628 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 5363b8588e7..7e714838311 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 869941d2e12..6295897fd54 100644 --- a/src/shared/agent-session-queued-submission.ts +++ b/src/shared/agent-session-queued-submission.ts @@ -2,7 +2,9 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' /** Accepted by the host and not yet handed to the provider: provably unwritten, so every way out * of this state is delivery or a rejection, never doubt. */ -export function isQueuedAgentJournalSubmission(submission: AgentJournalSubmission): boolean { +export function isQueuedAgentJournalSubmission( + submission: Pick +): boolean { return ( submission.handoverRecorded === true && submission.dispatchState === 'pending' && From f3e52d206b3c529bb3e151cde5b42fa60591e211 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:17:28 -0700 Subject: [PATCH 029/122] 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 0248799980a..a8c19781d71 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 459d21b1f3b..63d00ef5398 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 83555d63729..3498b6f5d88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -212,7 +212,13 @@ export async function performAttach( ...(input.openConversation ? { openConversation: input.openConversation } : {}), providerHistoryWindow }) - await importAdoptedTranscript(params, attached, record, preparedTranscript.items) + await importAdoptedTranscript( + params, + attached, + record, + preparedTranscript.items, + input.openConversation === undefined + ) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) await store.recordOperationOutcome({ callerKey: input.callerKey, From 29a340b0b483e752d863b40746ddbdfbaea5fd17 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:17:30 -0700 Subject: [PATCH 030/122] 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 214c889b5c9..1c60029c7d3 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 6e771a31809..29d324be971 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -67,7 +67,9 @@ export async function openAgentSessionJournalWithRecovery(input: { } const journal = await openAgentSessionJournal({ identity: input.identity, - journalDir: input.journalDir + journalDir: input.journalDir, + // The probe is this open's replay; omitted, not `null`, when there was nothing to load. + ...(probe ? { loaded: probe } : {}) }) if (!probe?.corrupt) { return { journal, recovery: null } From c7a652fe2925975061f5db168d2c4046e520af0c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:27:53 -0700 Subject: [PATCH 031/122] 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 dc69482a25b..8fc325a57e4 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 26cf103d362..6aeb6bc46d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -24,6 +24,7 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -132,6 +133,8 @@ async function runAttach( // attach makes it the session's; any other exit closes it with whatever the child queued. const attemptSink = context.runtimeState.mintEventSink(sessionId) let attemptSinkAdopted = false + let indexed: StructuredAgentSessionHostSession | null = null + let attachedOk = false const attached = stampFailedCreateOwnerVerdict( context.deps.store, callerKey, @@ -192,7 +195,7 @@ async function runAttach( ) context.runtimeState.adoptEventSink(sessionId, eventSink) attemptSinkAdopted = eventSink === attemptSink - context.sessions.set(sessionId, { + indexed = { journal: attached.journal, params, fence, @@ -202,7 +205,8 @@ async function runAttach( ? providerChildPhase : (previous?.providerChildPhase ?? 'ready'), acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null - }) + } + context.sessions.set(sessionId, indexed) await recoverStructuredRewind( context.deps.store, sessionId, @@ -218,12 +222,27 @@ async function runAttach( context.subscribers.publish(sessionId, attached.journal) } } - }).finally(() => { - if (!attemptSinkAdopted) { - attemptSink.close() - followRecordFence(context, sessionId) - } }) + .then((result) => { + attachedOk = result.ok + return result + }) + .finally(() => { + const releasedIndexedChild = + !attachedOk && indexed !== null && context.sessions.get(sessionId) === indexed + if (releasedIndexedChild && indexed) { + // The failure path released the child this attempt indexed; the conversation stays. + indexed.hasProviderChild = false + indexed.providerChildPhase = 'ready' + context.runtimeState.currentEventSink(sessionId)?.close() + context.runtimeState.discardEventSink(sessionId) + context.publishStatus?.(sessionId) + } + if (!attemptSinkAdopted || releasedIndexedChild) { + attemptSink.close() + followRecordFence(context, sessionId) + } + }) ) return attached } From 34d0bb0d34ec9eb6862238df001d94b5562282ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:37:04 -0700 Subject: [PATCH 032/122] 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 c1945503d27..815c1a2c403 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 9801caa80ad..c8e2f87420c 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 54168874636..72368dae921 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -73,6 +73,40 @@ function refusedRedelivery( ) } +/** Whether the journal already answers a send still in flight, so its own reply adds nothing: the + * host holds the message, or rejected it — a later `pending` reply must not undo that. */ +export function journalAnswersInFlightSend( + submissions: readonly AgentJournalSubmission[], + clientMessageId: string | null +): boolean { + return submissions.some( + (submission) => + submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown' + ) +} + +/** What to say for a message the reconcile just settled as accepted and then not delivered, which + * keeps its Retry; null when there is none. A Stop's withdrawal is dropped there, so says nothing. */ +export function reconciledRejectionNotice( + previous: readonly StructuredAgentSessionOutboxEntry[], + reconciled: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): string | null { + const was = ( + entries: readonly StructuredAgentSessionOutboxEntry[], + clientMessageId: string, + state: StructuredAgentSessionOutboxEntry['state'] + ): boolean => + entries.some((entry) => entry.clientMessageId === clientMessageId && entry.state === state) + const rejected = submissions.find( + (submission) => + submission.dispatchState === 'rejected' && + was(previous, submission.clientMessageId, 'dispatching') && + was(reconciled, submission.clientMessageId, 'rejected') + ) + return rejected ? structuredAgentSessionRejectionNotice(rejected.reason) : null +} + /** * What to put on screen for a rejection. * @@ -90,21 +124,6 @@ function refusedRedelivery( * Exported because a client without an outbox needs the same copy: the rule about * which reasons a person may read is a property of the reason, not of the queue. */ -/** A message the host accepted and then did not deliver: the chat says why and offers Retry. */ -export function rejectedDispatchingSubmission( - entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): AgentJournalSubmission | undefined { - return submissions.find( - (submission) => - submission.dispatchState === 'rejected' && - entries.some( - (entry) => - entry.clientMessageId === submission.clientMessageId && entry.state === 'dispatching' - ) - ) -} - export function structuredAgentSessionRejectionNotice(reason: string | null): string { if (reason === null) { return 'Message was not sent.' From 46b6472ab8766143c79123bb9052c83c06dc8059 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:41:15 -0700 Subject: [PATCH 033/122] 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 7f8fd0fea0d..c8ebb9eab63 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 a889ffb58f1..3ac97213869 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 aa8c7c58574..48ffe07d5d0 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 52cf3579016..a2beea9ea05 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 ecb3270874e..bc2b9601ceb 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 00000000000..13b31f1ad57 --- /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 a3c57a357d2..7e9118daffb 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 d1c9e59adfa..cc648ee7dde 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts @@ -19,6 +19,8 @@ export type WorkerTurnStartVerdict = 'observed' | 'permission' | 'unsupported' | export type WorkerTurnStartObservation = { verdict: WorkerTurnStartVerdict prompt?: RuntimeTerminalPromptDelivery + /** Why an `unobserved` start is unknown, when the default PTY wording does not fit. */ + reason?: string } function classifyPromptDelivery(prompt: RuntimeTerminalPromptDelivery): WorkerTurnStartVerdict { From 283819d17ad4a1b9375506599cf2873cfc5ec15a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:45:59 -0700 Subject: [PATCH 034/122] 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 815c1a2c403..fdbb5c67b06 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 f39b07f6627..1e6458a31b1 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -125,8 +125,12 @@ export function reconcileStructuredAgentSessionOutbox( return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] } // Accepted, then not delivered — the agent never started, or its start was refused. The text - // stays here for the user's Retry, and nothing queues behind it. - if (submission?.dispatchState === 'rejected' && entry.state === 'dispatching') { + // stays here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a + // remount reads an entry it left dispatching; the journal has since answered it. + if ( + submission?.dispatchState === 'rejected' && + (entry.state === 'dispatching' || entry.state === 'unconfirmed') + ) { return [{ ...entry, state: 'rejected' as const }] } if ( From 40dabe893fb11d27458d4eb77f740bece1767403 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:50:58 -0700 Subject: [PATCH 035/122] 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 13b31f1ad57..e39a06110dc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -29,7 +29,7 @@ function settle(delivered: 'accepted' | undefined) { ? { value: { clientMessageId: 'c1', submission: { dispatchState: delivered } } } : undefined } - const receipt = deliverAndSettleWorkerStartReadiness({ + const args = { runtime: { getNestedWorkerMaxDepth: () => 3, getTerminalOrchestrationCliCommand: () => 'orca' @@ -53,7 +53,9 @@ function settle(delivered: 'accepted' | undefined) { effects: [], terminalRevealWarning: undefined, onStage: () => {} - } as never) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fakes implement exactly the runtime, db and host members this settlement reaches. + const receipt = deliverAndSettleWorkerStartReadiness(args as never) return { db, receipt } } From 31444bf87614945890b19c1a0831627527550aba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:14:53 -0700 Subject: [PATCH 036/122] 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 f240d65273b..5a9a6f20801 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 1464ae5ae6c..4b9b46716cb 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 aba50a33ff4..f39a183b0f0 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 1fbc73e4afa..5d922d72350 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 34dc46f23b4..7681e0f3436 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 80c89f48ce2..b9b7021e854 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 b13a042a85f..ba508b67bcf 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 563cb660997..c23083fa687 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 a5a588559cd..82f8c551642 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 4c188d8c72d..af5e231730c 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 82a7d683a18..585cbd4fbbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -54,22 +54,21 @@ describe('structured agent-session outbound admission', () => { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES ) - let fence = 1 - const subscribers = new AgentSessionSubscribers({ readFence: () => fence }) + const subscribers = new AgentSessionSubscribers() const initial: AgentSessionSubscribeEvent[] = [] const dispose = subscribers.open({ id: 'initial', sessionId: SESSION, journal, + fence: 1, emit: (event) => initial.push(event) }) expect(initial).toHaveLength(1) expect(initial[0]).toMatchObject({ type: 'snapshot', page: { hasOlder: true } }) expectAdmitted(initial[0]) - fence = 2 - subscribers.backgroundTasks(SESSION, null) - subscribers.snapshot(SESSION, journal) + subscribers.backgroundTasks(SESSION, null, 2) + subscribers.snapshot(SESSION, journal, 2) expect(initial.slice(1)).toHaveLength(2) initial.slice(1).forEach(expectAdmitted) @@ -78,6 +77,7 @@ describe('structured agent-session outbound admission', () => { id: 'old-epoch', sessionId: SESSION, journal, + fence: 2, cursor: { epoch: 'retired-epoch', sequence: 1 }, emit: (event) => epochReset.push(event) }) @@ -109,7 +109,7 @@ describe('structured agent-session outbound admission', () => { }) it('splits valid-cursor catch-up into admitted batch frames', () => { - const subscribers = new AgentSessionSubscribers({ readFence: () => 2 }) + const subscribers = new AgentSessionSubscribers() const catchup: AgentSessionSubscribeEvent[] = [] const firstItemSequence = journal.snapshot().items[0]!.sequence @@ -117,6 +117,7 @@ describe('structured agent-session outbound admission', () => { id: 'catchup', sessionId: SESSION, journal, + fence: 2, cursor: { epoch: journal.epoch, sequence: firstItemSequence }, emit: (event) => catchup.push(event) }) From 494d8c7ac14edf403a108c4eb9de9deaa0e1633d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:26:32 -0700 Subject: [PATCH 037/122] 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 bea393eddbb..25df6aed9d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -1,6 +1,6 @@ // The effects behind send / cancel / respond / setOption. // -// Admission (lease, fence, idempotency) has already passed by the time anything +// Admission (writer lease, idempotency) has already passed by the time anything // here runs; these functions own only the journal writes and the adapter call, // in that order. Journal first is deliberate: a crash between the two leaves a // row the next attach settles as `unknown`, whereas the reverse would lose a From 0e74529dff9725b3fcb0ea8488e5a85f2fa9a086 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:36:45 -0700 Subject: [PATCH 038/122] 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 a6c90d1e01f..b7851709586 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -5,8 +5,8 @@ // lease-owning provider root through the adapter. Its observed exit is sufficient because the // lease follows that root, even when descendants remain `unverifiable`. // -// The fence still moves. A released lease at the old fence would let a mutation a client queued -// against the dead generation land on the next one. +// The fence still moves, so the next owner is a new generation: an attach or settlement still +// holding the stopped owner's fence is refused as stale rather than acting on its successor. import type { AgentSessionRecord } from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' From 6c16f353c6ae76f3a0bf4deb9d3a778d2bb6ff83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:42:55 -0700 Subject: [PATCH 039/122] 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 a95e71a30ef..e7270287fb5 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 11d72274bd4..14d2d4b2797 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 56455551349..e26301e379a 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 4fff2d387d0..cc32156cc87 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 223d4a56c9e..ec9269f4efe 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -11,8 +11,8 @@ * resolving the caller's worktree selector, because a selector resolution is a live precondition * and a replay must not be able to fail on one: an operation that already ran has an answer, and * re-deciding it against today's world is how a recorded success becomes a fresh refusal the client - * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the lease - * and the fence for that same reason. + * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the writer + * lease for that same reason. */ import { deriveAgentLaunchChildOperationId } from '../../../../shared/agent-launch-operation' From d2f646b87002b6c86254ee970c81b09c5f288064 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:47:49 -0700 Subject: [PATCH 040/122] 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 8fc325a57e4..91792f5875b 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 533f539466c..89fb4654f66 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 3498b6f5d88..5390e3f0f1e 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 6aeb6bc46d5..c93dabd549d 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 c449711e745..2637db9356d 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 8c99be697c5..303ecbe92fc 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 5b20c537cfc..eb850e1941f 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 202ffe50ae4..7014e394728 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 7bdc65119c6..9d524633959 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 89a49213088..ac3057b81e8 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 cb0cb2fb233..83f8ade9a4c 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 951bbd2ab32..5ff00733dee 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 304cadf8f86..1ffc08a0249 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 68027f81887..9b6718e9957 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 3065c464329..8b25364c881 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 e3b284acb5b..91035098257 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 971c63aa6fc..5f2e7bd7452 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 fd2c911c74e..84fa67b9331 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 ba059204cb1..bfe626813bd 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 ef08e1c7e93..942c9ef6152 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 00000000000..3f21a807673 --- /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 00000000000..56be117bc4a --- /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 55c718f967b..6d6d910cc55 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 21c3d9a5f7f..514008e49f9 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 c025c0dc46a..87a56efb7b9 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 41ae0a1b1a8..61e61debe14 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 29453e74652..0fa4ee8534d 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 e5b65009634..68823c078dc 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 1d6a8a1fc5e..09a172f0b1d 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 318f2192cc2..8bf3bc2242c 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 976e9417628..4ea4b4e13a3 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 1000881220e..8d19420feb4 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 516122c091c..52a1d02f7c2 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 c7f61d5c9ae..c8b5b1f40c5 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 e4fa6ebed1b..c130aaf9d59 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 65c7f54fb30..2c2863707c8 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 29bf29a00f1..5403ec16abe 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 cf908932667..4053988233f 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 c8b8839318f..1e0344a7b07 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 79f21a349b4..952fa70657d 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 bba6154af43..5aae86f3291 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 7e714838311..212963b8539 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 5a3c2497c34..820f4c3c95d 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 b1e43a9025d..27d5ba48b29 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 b9210bf79e8..71bb0fddce1 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -143,7 +143,9 @@ async function openHost() { journalDir: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ - sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, hasProviderChild: true })]]), + sessions: new Map([ + [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] + ]), getRecord: () => null, now: () => 1, readBackgroundTasks: () => ({ state: 'monitoring', tasks: [] }) From aa56b5033c347ff522f6b7e5260b12a297dea4c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:47:10 -0700 Subject: [PATCH 041/122] 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 0b8acadf303..6e90d1547bd 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 10f6821fcc5..c356d7904b2 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 91792f5875b..186127847a6 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 4a8a187becd..566e41a6635 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 0c44a31bbe4..d261628956e 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 c8c908aae70..9f38c85755f 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 eb850e1941f..ceaa951e4d7 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 6d7d8618ad4..8ad51871c24 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 0675d8730a6..f79cbfcbc6b 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 9d524633959..3ea91214ad0 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 8b25364c881..41b83e77eae 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 91035098257..ebc718443d5 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 6b1693c3dca..2f1b0f3c287 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 bfe626813bd..5976ea26498 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 3f21a807673..c99467db927 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 52a1d02f7c2..00594966101 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 c8b5b1f40c5..ab2c8f0b9e8 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 2c2863707c8..e5da2eece97 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 5aae86f3291..42533ba2fe3 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 212963b8539..7f4a60005b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -9,8 +9,6 @@ import type { StructuredAgentSessionSinkBarrier } from './structured-agent-sessi import { captureUnfinishedStructuredAgentSessionWork, MAX_UNEXPECTED_EXIT_REASON_CHARS, - providerExitBeforeDeliveryRejection, - providerStartupFailureRejection, settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted @@ -213,10 +211,9 @@ async function retryUnexpectedExitSettlement(input: { pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, unexpectedExitReason: input.event.reason, - exitedDuringStartup: input.exitedDuringStartup, - queuedRejection: input.exitedDuringStartup - ? providerStartupFailureRejection(input.event.reason) - : providerExitBeforeDeliveryRejection(input.event.reason), + ...(input.exitedDuringStartup + ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } + : {}), onError: input.context.onBarrierError }) } From 4993696a5330f54d01e5bd1e924b9a4023e9e46e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:01:21 -0700 Subject: [PATCH 042/122] 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 89fb4654f66..6af8eb369d5 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 5390e3f0f1e..bcc47bce67f 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 1b6064726a3..cc311aee77e 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 83f8ade9a4c..8fc810de2a4 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 cc4dcc46be9..2c03143ff41 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 33606f67d2d..424aed3f95f 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 84fa67b9331..63333355687 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 c99467db927..be67339f161 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 42533ba2fe3..fd04a946aa3 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 7f4a60005b8..f30521ab807 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -78,7 +78,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< fence: child.fence, cause: 'exit', reason: unexpectedEvent.reason, - duringStartup: exitedDuringStartup + duringStartup: exitedDuringStartup, + // The adapter publishes an exit only once it saw the root go, first-hand or proven. + rootGone: true }) context.publishStatus?.(unexpectedEvent.sessionId) } From a2ad8f2b635ea53753aa9a60691096ae03c2cdd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:04:17 -0700 Subject: [PATCH 043/122] 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 70da8409c37..07e006ac6c7 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 4ae97924dbc..8a4e269d33d 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -374,6 +374,9 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + // The host side: it accepts a send before any agent has it, and a Stop with no writer before a + // turn starts, so a client may gate on either. + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, From 48def968ef02efb8279ccfc1e8b8ed3a18de61e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:17:16 -0700 Subject: [PATCH 044/122] 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 e0549e1816e..d49637e3bb7 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 a8c19781d71..4b8e1d87ba3 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 63d00ef5398..7a85de6a473 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 bcc47bce67f..b0108fd6773 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 cc311aee77e..743152b76b6 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 81fb5f92738..4e15b23d4d3 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 00000000000..4b8037077aa --- /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 6b75b159a33..e1ddea2c325 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 0f115da5ebd..83b1e138d09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -12,6 +12,7 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' const NOW = 1_800_000_000_000 @@ -86,6 +87,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -133,6 +135,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -172,6 +175,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-drift', claimKeyId: 'key-1', @@ -226,6 +230,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -297,6 +302,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', From da65d574f8dfe1aec407cf276bd9a4ea75172253 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:23:12 -0700 Subject: [PATCH 045/122] 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 be67339f161..e13f17dff8f 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 fc2d5adf150..e0378b6cfe3 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 00000000000..fd306a53730 --- /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 c8e2f87420c..2c9563e5744 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 00000000000..acdfdbb0323 --- /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 00000000000..c6e130b8bd5 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts @@ -0,0 +1,87 @@ +import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' +import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeClientTarget } from './runtime-client-target' +import { + ensureLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown +} from './local-runtime-capabilities' +import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' + +function targetKey(target: RuntimeClientTarget): string { + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} + +/** + * Whether the host answers a send at acceptance and never refuses one because its agent could not + * start. Such a host records every send before it starts anything, so nothing about a moved fence + * calls for a resend. False until the host has said so: an older host is handled as it always was. + */ +export function useStructuredAgentSessionHostAcceptsSend(target: RuntimeClientTarget): boolean { + const key = targetKey(target) + const [answer, setAnswer] = useState<{ key: string; accepts: boolean }>(() => ({ + key, + accepts: + target.kind === 'local' && + (readLocalRuntimeCapabilitiesOrUnknown()?.includes( + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) ?? + false) + })) + const environmentId = target.kind === 'environment' ? target.environmentId : null + useEffect(() => { + let cancelled = false + const probe = + environmentId === null + ? ensureLocalRuntimeCapabilities().then( + (capabilities) => + capabilities?.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) ?? false + ) + : runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) + void probe + .catch(() => false) + .then((accepts) => { + if (!cancelled) { + setAnswer((current) => + current.key === key && current.accepts === accepts ? current : { key, accepts } + ) + } + }) + return () => { + cancelled = true + } + }, [environmentId, key]) + return answer.key === key && answer.accepts +} + +/** + * When an outbox treats its owner as changed: resending a send in flight under the same id, + * dropping that send's answer, and unblocking a refused head. An older host restarts the agent + * inside the send and refuses it, unrecorded, when that fails, so a new fence is its only word that + * another try may land. A host that accepts first records every send before it starts anything, + * so a moved fence means nothing there, and only a Retry or a new send goes out. + */ +export function useStructuredAgentSessionOutboxOwnerChange( + target: RuntimeClientTarget, + fence: number | null +): { + ownerChange: number | null + attached: boolean + fenceRef: RefObject + targetKey: string +} { + const acceptsSend = useStructuredAgentSessionHostAcceptsSend(target) + // Read by effects that run on an owner change, not on every fence move. + const fenceRef = useRef(fence) + useLayoutEffect(() => { + fenceRef.current = fence + }, [fence]) + return { + ownerChange: acceptsSend ? null : fence, + attached: fence !== null, + fenceRef, + targetKey: targetKey(target) + } +} From 0184a3d814bf7ef25049195d55130d7c7734fc26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:42:51 -0700 Subject: [PATCH 046/122] 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 3ea91214ad0..936fc639015 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 424aed3f95f..6b2b869548c 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 63333355687..5a7161949c8 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 d690512ae32..c35a0ed1cbb 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 e13f17dff8f..51f74871dc2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -20,6 +20,7 @@ import { providerStartupFailureOutcome } from './structured-agent-session-dead-g import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -266,7 +267,7 @@ describe('Stop on a child still proving its start', () => { // The same conversation: no reopen, the holder kept, and the chat told it is idle again. expect(conversation()?.journal).toBe(journal) expect(conversation()?.child).toBeNull() - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'stop', rootGone: true }) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) expect(host.isHeld(SESSION)).toBe(true) expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') @@ -511,3 +512,51 @@ describe('a send whose start failed, sent again with the same operation id', () async function settleLoop(): Promise { await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) } + +describe('how a stopped child ends the start its loop was waiting on', () => { + /** A child the loop waits on, whose start the stop below does not settle, so a message sent + * after the stop is queued when the loop next looks. */ + async function stoppedWhileStarting(stop: () => Promise) { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await stop() + const second = await accept('second') + adapterExtras.awaitStarted = undefined + start.resolve() + return second + } + + it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { + const second = await stoppedWhileStarting(async () => { + expect(await stop()).toMatchObject({ ok: true }) + }) + + await eventually(() => expect(submission(second)?.dispatchState).toBe('accepted')) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) + expect(statusRows()).toEqual([]) + }) + + it('fails the start after a host stop, with the stop as the reason (R2)', async () => { + const reason = 'Claude never finished starting, so Orca stopped it.' + const second = await stoppedWhileStarting(() => + host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'host-stop', + reason + }) + ) + ) + + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + expect(submission(second)?.reason).toBe(reason) + expect(statusRows()).toEqual([{ itemId: expect.any(String), text: reason, tone: 'error' }]) + expect(dispatch).not.toHaveBeenCalled() + }) +}) From 12577d84864be8f4baa98645f0086add50730ae3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:16:22 -0700 Subject: [PATCH 047/122] 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 09a172f0b1d..5cfd8012fe1 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 820f4c3c95d..e68df2c09a6 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 048/122] 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 5cfd8012fe1..7a8463d2fb9 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 049/122] 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 936fc639015..da6c341ce87 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 5a7161949c8..44611300c7c 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 51f74871dc2..fa252d2ce4f 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 56be117bc4a..1a792e600b4 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 ab2c8f0b9e8..88774c63bed 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 fd04a946aa3..1dc71d3a70a 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 f30521ab807..4874716d8cc 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 050/122] 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 e1ddea2c325..383aaaeb319 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 38849ab0f05..80241782910 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 051/122] 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 fa252d2ce4f..bf3562282a7 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 7a8463d2fb9..5dc939d7e66 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 052/122] 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 bf3562282a7..5abe04e8552 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 053/122] 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 c8ebb9eab63..86568a0435f 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 3ac97213869..06f657924b1 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 568bb46515316578954185ba8b985ce34c6722d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:56:02 -0700 Subject: [PATCH 054/122] feat(native-chat): a typed failure fact beside every failure sentence Adds the shared vocabulary the host writes a failure with: a closed failure kind, a provider diagnostic that says who it is for (a person, or a log), and a refusal cause beside the refusal code. Status rows gain an optional failure fact and rejected submissions an optional rejection fact; the dispatch row carries it, the reducer reads it field by field, and the projection forwards it. Older rows and older readers are untouched: every field is optional and the schemas stay open. --- .../journal-dispatch-reducer.ts | 52 +++++++ .../journal-reducer.test.ts | 54 +++++++ .../agent-session-journal/journal-reducer.ts | 40 +---- .../journal-row-builders.ts | 4 + .../journal-row-schema.ts | 4 + .../journal-store-contracts.ts | 5 +- src/shared/agent-session-failure.test.ts | 66 ++++++++ src/shared/agent-session-failure.ts | 139 +++++++++++++++++ .../agent-session-journal-schemas.test.ts | 35 +++++ src/shared/agent-session-journal-schemas.ts | 14 +- src/shared/agent-session-journal-types.ts | 9 +- src/shared/agent-session-wire-refusals.ts | 144 ++++++++++++++++++ src/shared/native-chat-types.ts | 3 + ...ctured-agent-session-dispatch-rejection.ts | 35 +++++ ...tructured-agent-session-projection.test.ts | 19 +++ .../structured-agent-session-projection.ts | 14 +- .../structured-agent-session-status-block.ts | 18 +++ 17 files changed, 601 insertions(+), 54 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts create mode 100644 src/shared/agent-session-failure.test.ts create mode 100644 src/shared/agent-session-failure.ts create mode 100644 src/shared/structured-agent-session-status-block.ts diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts new file mode 100644 index 00000000000..4e565e4ae50 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -0,0 +1,52 @@ +// How a `dispatch` row settles its submission. Field by field, so a key the row gains must be +// copied here to reach any reader. + +import { readAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +export function applyJournalDispatchRow( + state: JournalReducerState, + row: Extract +): void { + const submission = state.submissions.get(row.clientMessageId) + if (!submission) { + return + } + // `rejected` is terminal; a late `unknown` must not reopen a settled answer. + if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { + return + } + submission.fence = row.fence + submission.dispatchState = row.state + submission.providerItemId = row.providerItemId + submission.reason = row.reason + // Read, not copied: a row from disk may carry a fact this build cannot place. + const rejection = + row.state === 'rejected' ? readAgentSessionFailureFact(row.rejection) : undefined + if (rejection) { + submission.rejection = rejection + } else { + delete submission.rejection + } + submission.resolvedAt = row.state === 'pending' ? null : row.ts + if (row.state === 'pending') { + submission.handedOverAt = row.ts + } + if (row.recovered) { + submission.recovered = row.recovered + } else { + delete submission.recovered + } + if (row.state !== 'accepted' || !row.providerItemId) { + return + } + state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) + state.receipts.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + providerItemId: row.providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 825c5aaf347..bef520d0ad6 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -285,6 +285,60 @@ describe('submission and dispatch state machine', () => { expect(state.receipts.get('cm_2')?.providerItemId).toBe('claude:session-1:user-1') }) + it("copies a rejection's typed fact onto the submission, and only on `rejected`", () => { + const rejection = { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } } + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } }, + ...base(2) + } + ]) + expect(state.submissions.get('cm_1')).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message.', + rejection + }) + + const doubt = fold([ + { ...submission, clientMessageId: 'cm_2' }, + { + kind: 'dispatch', + clientMessageId: 'cm_2', + state: 'unknown', + providerItemId: null, + reason: 'provider_exited_before_acknowledgement', + rejection: { kind: 'writeFailed' }, + ...base(2) + } + ]) + expect(doubt.submissions.get('cm_2')).not.toHaveProperty('rejection') + }) + + it('drops a rejection fact it cannot place but keeps the row', () => { + const rejected: JournalRow = { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'Not sent.', + ...base(2) + } + // A newer host's kind, as a row read from disk would carry it. + Object.assign(rejected, { rejection: { kind: 'futureKind' } }) + const state = fold([submission, rejected]) + expect(state.submissions.get('cm_1')).toMatchObject({ + dispatchState: 'rejected', + reason: 'Not sent.' + }) + expect(state.submissions.get('cm_1')).not.toHaveProperty('rejection') + }) + it('does not give a newer identical echo to a legacy unknown write failure', () => { const body = userText('same message') const state = fold([ 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 3fbee85a417..2ff71d936c3 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -26,6 +26,7 @@ import { import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' +import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { dispatchRejectionWasTransportWriteFailure } from '../../../shared/structured-agent-session-dispatch-rejection' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -120,7 +121,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo applySubmission(state, row) return } - applyDispatch(state, row) + applyJournalDispatchRow(state, row) } export function rememberAppliedSettlementId( @@ -261,43 +262,6 @@ function applySubmission( upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row)) } -function applyDispatch( - state: JournalReducerState, - row: Extract -): void { - const submission = state.submissions.get(row.clientMessageId) - if (!submission) { - return - } - // `rejected` is terminal; a late `unknown` must not reopen a settled answer. - if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { - return - } - submission.fence = row.fence - submission.dispatchState = row.state - 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 { - delete submission.recovered - } - if (row.state !== 'accepted' || !row.providerItemId) { - return - } - state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) - state.receipts.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - providerItemId: row.providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - function acceptSubmissionFromProviderItem( state: JournalReducerState, providerItemId: string, 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 e56ca86a7fb..21a8067ccce 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 @@ -1,3 +1,4 @@ +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchState, AgentJournalItemBody, @@ -85,6 +86,7 @@ export function journalDispatchRowBuilder( dispatchState: input.state, providerItemId, reason: boundedDispatchReason(input), + rejection: input.state === 'rejected' ? input.rejection : undefined, seq, fence: input.fence, ts, @@ -287,6 +289,7 @@ export function buildJournalDispatchRow(input: { dispatchState: AgentJournalDispatchState providerItemId: string | null reason: string | null + rejection?: AgentSessionFailureFact seq: number fence: number ts: number @@ -298,6 +301,7 @@ export function buildJournalDispatchRow(input: { state: input.dispatchState, providerItemId: input.providerItemId, reason: input.reason, + ...(input.rejection ? { rejection: input.rejection } : {}), ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.recovered ? { recovered: input.recovered } : {}) } 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 0f29f8b3879..786d422876c 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 @@ -5,6 +5,7 @@ // UNREADABLE, not skippable: the caller must degrade to read-only rather than // render a partial timeline or compact past a row it cannot interpret. +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalDispatchState, @@ -89,6 +90,9 @@ export type JournalDispatchRow = JournalRowBase & { /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null + /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is + * dropped when read, never the row. */ + rejection?: AgentSessionFailureFact } /** An item mutation may name its own producer, because one batch can CREATE 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 79d86f80dbb..eb802b36fed 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 @@ -1,3 +1,4 @@ +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalCursor, AgentJournalItemBody, @@ -31,7 +32,9 @@ export type ResolveDispatchInput = { } & ( | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } | { state: 'pending' } - | { state: 'rejected' | 'unknown'; reason?: string | null } + /** `reason` is the sentence released clients print; `rejection` is what newer ones read. */ + | { state: 'rejected'; reason?: string | null; rejection?: AgentSessionFailureFact } + | { state: 'unknown'; reason?: string | null } ) export type JournalAppendResult = { diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts new file mode 100644 index 00000000000..48b5161128c --- /dev/null +++ b/src/shared/agent-session-failure.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic, + providerDiagnosticOf, + readAgentSessionFailureFact, + withProviderDiagnostic +} from './agent-session-failure' + +describe('provider diagnostics', () => { + it('bounds the text and records nothing for an empty one', () => { + expect(providerDiagnostic(' ', 'log')).toBeUndefined() + expect( + providerDiagnostic('x'.repeat(MAX_PROVIDER_DIAGNOSTIC_CHARS + 50), 'log')?.text + ).toHaveLength(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it('is read from the error that carries it, or from what it wraps', () => { + const diagnostic = { text: 'bad request', audience: 'person' as const } + const carried = withProviderDiagnostic( + new Error('codex turn/start failed: bad request'), + diagnostic + ) + expect(providerDiagnosticOf(carried)).toEqual(diagnostic) + expect(providerDiagnosticOf(new Error('wrapped', { cause: carried }))).toEqual(diagnostic) + expect(providerDiagnosticOf(new AggregateError([new Error('other'), carried], 'both'))).toEqual( + diagnostic + ) + }) + + it('is never inferred from an error that did not carry one', () => { + // Orca's own wording, even when it quotes something that looks like a provider message. + expect( + providerDiagnosticOf(new Error('claude stream-json exited (code 1): not signed in')) + ).toBe(undefined) + expect(providerDiagnosticOf('a string')).toBeUndefined() + }) +}) + +describe('reading a failure fact', () => { + it('keeps what this build can place', () => { + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' }, + detail: { text: 'x', audience: 'person' } + }) + ).toEqual({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' }, + detail: { text: 'x', audience: 'person' } + }) + }) + + it('drops what a newer host wrote that this build cannot place', () => { + expect(readAgentSessionFailureFact({ kind: 'futureKind' })).toBeUndefined() + expect(readAgentSessionFailureFact(undefined)).toBeUndefined() + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'futureCause' }, + detail: { text: 'x', audience: 'future' } + }) + ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) + }) +}) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts new file mode 100644 index 00000000000..97d3af94ae9 --- /dev/null +++ b/src/shared/agent-session-failure.ts @@ -0,0 +1,139 @@ +// What went wrong in a chat, typed where the host decides it, beside the sentence a person reads. +// +// The host writes both at once: `text`/`reason` stays a complete sentence because released clients +// print it as it is, and this fact is what newer clients choose their copy and action from. A +// provider's own words ride separately as `detail`, set only where Orca composed them from a +// value the provider wrote — never recovered from a string afterwards, since by then nothing can +// tell a provider's sentence from Orca's. + +import { + isAgentSessionWireRefusalCode, + type AgentSessionRefusalReference, + isAgentSessionRefusalCause +} from './agent-session-wire-refusals' + +/** One per situation with its own honest next step; a new one needs copy before it compiles. */ +export const AGENT_SESSION_FAILURE_KINDS = [ + 'providerStartFailed', + 'notSignedIn', + 'historyTooLarge', + 'providerExited', + 'restartFailed', + 'providerRejected', + 'attachmentInvalid', + 'attachmentUnreadable', + 'queueFull', + 'writeFailed', + 'cancelled', + 'chatClosed', + 'hostRestarted', + 'notDelivered', + 'compactionFailed', + 'compactionUnconfirmed', + 'cancelUnconfirmed', + 'answerUnconfirmed', + 'hostFault' +] as const +export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] + +export function isAgentSessionFailureKind(value: unknown): value is AgentSessionFailureKind { + return typeof value === 'string' && AGENT_SESSION_FAILURE_KINDS.some((kind) => kind === value) +} + +/** `person`: written by the provider for whoever reads the chat, shown inline. `log`: a stderr + * tail or exit status, shown only behind Details. */ +export type ProviderDiagnosticAudience = 'person' | 'log' + +export type ProviderDiagnostic = { + text: string + audience: ProviderDiagnosticAudience +} + +/** Stderr can be a whole dump; the row keeps enough to act on. */ +export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 2000 + +export type AgentSessionFailureFact = { + kind: AgentSessionFailureKind + /** Provider-authored only; absent whenever Orca wrote the words. */ + detail?: ProviderDiagnostic + /** On `restartFailed`: the refusal that kept the agent from starting. */ + refusal?: AgentSessionRefusalReference +} + +/** Null for empty text, so a writer never records a detail with nothing in it. */ +export function providerDiagnostic( + text: string, + audience: ProviderDiagnosticAudience +): ProviderDiagnostic | undefined { + const bounded = text.trim().slice(0, MAX_PROVIDER_DIAGNOSTIC_CHARS).trim() + return bounded ? { text: bounded, audience } : undefined +} + +export function agentSessionFailureFact( + kind: AgentSessionFailureKind, + extra: { detail?: ProviderDiagnostic; refusal?: AgentSessionRefusalReference } = {} +): AgentSessionFailureFact { + return { + kind, + ...(extra.detail ? { detail: extra.detail } : {}), + ...(extra.refusal ? { refusal: extra.refusal } : {}) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic { + return ( + isRecord(value) && + typeof value.text === 'string' && + (value.audience === 'person' || value.audience === 'log') + ) +} + +/** A fact as a reader meets it. Undefined for anything this build cannot place, including a kind a + * newer host added, so the reader falls back to what it does for a row with no fact. */ +export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined { + if (!isRecord(value) || !isAgentSessionFailureKind(value.kind)) { + return undefined + } + const refusal = + isRecord(value.refusal) && isAgentSessionWireRefusalCode(value.refusal.code) + ? { + code: value.refusal.code, + ...(isAgentSessionRefusalCause(value.refusal.cause) ? { cause: value.refusal.cause } : {}) + } + : undefined + return agentSessionFailureFact(value.kind, { + ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), + ...(refusal ? { refusal } : {}) + }) +} + +/** The provider-authored diagnostic an error carries, set only where it was composed. Follows the + * `cause` chain, since wrappers such as the acquisition errors keep the original as their cause. */ +export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined { + let current: unknown = error + for (let depth = 0; depth < 6 && current instanceof Error; depth += 1) { + if ('providerDiagnostic' in current && isProviderDiagnostic(current.providerDiagnostic)) { + return current.providerDiagnostic + } + if (current instanceof AggregateError) { + const found = current.errors.map(providerDiagnosticOf).find(Boolean) + if (found) { + return found + } + } + current = current.cause + } + return undefined +} + +/** Attaches the provider's words to the error Orca built around them. */ +export function withProviderDiagnostic( + error: TError, + diagnostic: ProviderDiagnostic | undefined +): TError { + return diagnostic ? Object.assign(error, { providerDiagnostic: diagnostic }) : error +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index ec66caaa059..02a156f2b0d 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -332,6 +332,41 @@ describe('optional notice metadata', () => { }) }) +describe('typed failure facts', () => { + it('admits a status row and a submission with a fact, and the same rows without one', () => { + const failure = { + kind: 'providerStartFailed', + detail: { text: 'exit status 1', audience: 'log' } + } + expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Stopped.', failure })).toBe( + true + ) + expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Stopped.' })).toBe(true) + const submission = { + clientMessageId: 'cm-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Not sent.', + submittedAt: 1, + resolvedAt: 2 + } + expect(isAdmissibleAgentJournalSubmission(submission)).toBe(true) + expect(isAdmissibleAgentJournalSubmission({ ...submission, rejection: failure })).toBe(true) + }) + + it('keeps a kind or audience a newer host writes admissible', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'status', + text: 'Stopped.', + failure: { kind: 'futureKind', detail: { text: 'x', audience: 'future' } } + }) + ).toBe(true) + }) +}) + describe('optional tool annotations', () => { const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' } it('admits old rows and rows with optional annotations without a new kind', () => { diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index a978f469547..24082bd8622 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -190,6 +190,14 @@ const ThreadGoalState = z.union([ z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) ]) +/** Open like `state`: a kind or audience a newer host writes must not turn the row malformed; the + * fact reader is where an unplaceable one is dropped. */ +const FailureFact = z.object({ + kind: z.string().min(1), + detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), + refusal: z.object({ code: z.string().min(1), cause: z.string().min(1).optional() }).optional() +}) + export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -242,7 +250,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ }) .optional(), providerFrame: ProviderFrame.optional(), - threadGoal: ThreadGoalState.optional() + threadGoal: ThreadGoalState.optional(), + failure: FailureFact.optional() }), z.object({ kind: z.literal('turn'), @@ -297,7 +306,8 @@ export const AgentJournalSubmissionSchema = z.object({ resolvedAt: z.number().nullable(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), - handedOverAt: z.number().optional() + handedOverAt: z.number().optional(), + rejection: FailureFact.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 233acf3bbbe..e743878bdf6 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -7,6 +7,7 @@ // rewritten in place, so a host that cannot read a row refuses to write the // journal rather than skipping or compacting past it. +import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentType } from './agent-status-types' import type { AgentJournalTurnOutcome } from './agent-turn-outcome' import type { NativeChatToolMetadata } from './native-chat-tool-identity' @@ -272,6 +273,9 @@ export type AgentJournalStatusItem = { } /** Present on thread-goal transitions; absent on rows from older hosts. */ threadGoal?: AgentJournalThreadGoalState + /** On a row that reports a failure: what failed, typed. `text` stays the sentence older + * clients print; absent on rows from older hosts. */ + failure?: AgentSessionFailureFact } /** The durable record of one root turn. `running` exposes cancellation while @@ -353,8 +357,11 @@ export type AgentJournalSubmission = { dispatchState: AgentJournalDispatchState /** Provider item identity adopted on accept; null otherwise. */ providerItemId: string | null - /** Terminal reason on `rejected`. */ + /** Terminal reason on `rejected`: a sentence a person can read, or one of the legacy markers + * older clients already recognise. On `unknown`, the doubt marker. */ reason: string | null + /** On `rejected`, why, typed; absent on rows from older hosts. */ + rejection?: AgentSessionFailureFact submittedAt: number resolvedAt: number | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live diff --git a/src/shared/agent-session-wire-refusals.ts b/src/shared/agent-session-wire-refusals.ts index 12cd63d4490..b59995825ed 100644 --- a/src/shared/agent-session-wire-refusals.ts +++ b/src/shared/agent-session-wire-refusals.ts @@ -38,9 +38,100 @@ export function isAgentSessionWireRefusalCode( /** What the host last proved about a session's provider process; see the SSH execution boundary. */ export type AgentSessionOwnerVerdict = 'live' | 'unverifiable' | 'exited' +/** + * Why the host refused, one per situation with its own honest next step. A code alone cannot say: + * `agent_session_operation_invalid` covers a cleared conversation (go to the current one) and a + * busy command (wait), and `agent_session_ownership_unknown` covers an unattached session (wait) + * and a replay the lease moved past (start again). Set at every emitter a chat write can reach; + * terminal-agent refusals carry none. Absent from older hosts, so a reader falls back to the code. + */ +export const AGENT_SESSION_REFUSAL_CAUSES = [ + // The request itself + 'operationIdInvalid', + 'operationIdReused', + 'operationExpired', + 'operationCapacity', + 'operationRefusedEarlier', + 'outcomeUnknown', + 'resultLost', + 'fingerprintMismatch', + 'requestMalformed', + 'messageIdReused', + 'clientCapabilityMissing', + // The host + 'hostDisabled', + 'hostUnsupported', + 'hostReconciling', + 'hostFault', + 'recordUnreadable', + 'recordMissing', + 'journalUnreadable', + 'journalWriteFailed', + // The session's owner and lease + 'sessionNotAttached', + 'noLiveOwner', + 'noProviderChild', + 'chatStarting', + 'ownerUnproven', + 'ownerAlive', + 'exitUnproven', + 'claimConflicted', + 'settlementPending', + 'notResumable', + 'leaseMoved', + 'replaySuperseded', + 'spawnIdentityMismatch', + 'identityMismatch', + 'fenceStale', + 'handoffInFlight', + // Creating or adopting a chat + 'sessionExists', + 'tabIdTaken', + 'tabUnconfirmed', + 'conversationHeldElsewhere', + 'conversationUnavailable', + 'transcriptNotFound', + 'transcriptUnreadable', + // The provider + 'providerStarting', + 'providerStartFailed', + 'providerRejected', + 'notSignedIn', + 'historyTooLarge', + 'optionRejected', + 'goalsUnsupported', + // The conversation's state + 'conversationCleared', + 'conversationCommandInFlight', + 'conversationCommandUnconfirmed', + 'rewindRefused', + 'rewindUnconfirmed', + 'turnActive', + 'promptPending', + 'backgroundTasksRunning', + 'messagesUnsettled', + // A prompt card + 'promptGone', + 'promptMoved', + 'promptAlreadyResolved' +] as const +export type AgentSessionRefusalCause = (typeof AGENT_SESSION_REFUSAL_CAUSES)[number] + +export function isAgentSessionRefusalCause(value: unknown): value is AgentSessionRefusalCause { + return typeof value === 'string' && AGENT_SESSION_REFUSAL_CAUSES.some((cause) => cause === value) +} + +/** A refusal named without its prose: what a durable record or an error's data keeps. */ +export type AgentSessionRefusalReference = { + code: AgentSessionWireRefusalCode + cause?: AgentSessionRefusalCause +} + export type AgentSessionWireRefusal = { rewindReason?: AgentSessionRewindReason code: AgentSessionWireRefusalCode + /** Absent from older hosts and from refusals no chat write reaches. */ + cause?: AgentSessionRefusalCause message: string /** On a stale fence, so the client can retry without another round trip. */ currentFence?: number @@ -52,3 +143,56 @@ export type AgentSessionWireRefusal = { * operation cannot collide with this one. Absent (older hosts) reads as unverifiable. */ ownerVerdict?: AgentSessionOwnerVerdict } + +/** A refusal built at its emitter, with the situation it stands for. */ +export function refuse( + code: AgentSessionWireRefusalCode, + cause: AgentSessionRefusalCause, + message: string +): AgentSessionWireRefusal { + return { code, cause, message } +} + +/** The refusal reduced to what may be stored or put in an error's data. */ +export function agentSessionRefusalReference( + refusal: Pick +): AgentSessionRefusalReference { + return { code: refusal.code, ...(refusal.cause ? { cause: refusal.cause } : {}) } +} + +/** + * A refusal raised rather than returned. Its message is the bare code, exactly as the + * `Error(code)` it replaces: the RPC passthrough, the restart-resume ledger and released clients + * all read a thrown refusal's message as its code. The cause rides on `refusal` and reaches the + * client only in the RPC error's data. Deliberately no `code` property, so no `'code' in error` + * passthrough meant for another subsystem can claim it. + */ +export class AgentSessionRefusalError extends Error { + readonly refusal: AgentSessionWireRefusal + + constructor(refusal: AgentSessionWireRefusal, options?: { cause?: unknown }) { + super(refusal.code, options) + this.name = 'AgentSessionRefusalError' + this.refusal = refusal + } +} + +/** A store or host refusal thrown under its situation; the message stays the code. */ +export function agentSessionRefusalError( + code: AgentSessionWireRefusalCode, + cause: AgentSessionRefusalCause, + message: string = code +): AgentSessionRefusalError { + return new AgentSessionRefusalError(refuse(code, cause, message)) +} + +export function isAgentSessionRefusalError(error: unknown): error is AgentSessionRefusalError { + return error instanceof AgentSessionRefusalError +} + +/** The situation a thrown refusal named, whatever wrapped it. */ +export function thrownAgentSessionRefusalCause( + error: unknown +): AgentSessionRefusalCause | undefined { + return error instanceof AgentSessionRefusalError ? error.refusal.cause : undefined +} diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 24bf8652fb2..0ead3548841 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -10,6 +10,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' +import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode } from './agent-session-journal-types' import type { AgentType } from './agent-status-types' import type { NativeChatToolMetadata } from './native-chat-tool-identity' @@ -51,6 +52,8 @@ export type NativeChatTextBlock = { truncated: boolean } } + /** On a status line that reports a failure: what failed, typed. */ + failure?: AgentSessionFailureFact } /** A tool invocation by the agent. `input` is the (already-serialized) tool diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index a88dad5b16e..7b4e1574491 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -18,6 +18,8 @@ // reason, and a client has to know which kind it is holding before it can decide // whether the string is showable. +import type { AgentSessionFailureFact } from './agent-session-failure' + export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' /** Local admission refused the frame before any transport was involved. Two @@ -40,6 +42,39 @@ export function dispatchWriteFailureReason(error: unknown): string { return `${DISPATCH_REJECTED_WRITE_FAILED}: ${detail}` } +/** A rejection as the host writes it: the sentence (or legacy marker) released clients print, and + * the typed fact newer ones read. */ +export type AgentJournalDispatchRejection = { + reason: string + rejection: AgentSessionFailureFact +} + +export const DISPATCH_REJECTION_CANCELLED: AgentJournalDispatchRejection = { + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } +} + +export const DISPATCH_REJECTION_HOST_RESTARTED: AgentJournalDispatchRejection = { + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } +} + +export const DISPATCH_REJECTION_PROVIDER_CLOSED: AgentJournalDispatchRejection = { + reason: DISPATCH_REJECTED_PROVIDER_CLOSED, + rejection: { kind: 'chatClosed' } +} + +/** The marker keeps its Orca text after the prefix, as released clients expect; they hide it. */ +export function dispatchWriteFailureRejection(error: unknown): AgentJournalDispatchRejection { + return { reason: dispatchWriteFailureReason(error), rejection: { kind: 'writeFailed' } } +} + +export function dispatchQueueFullRejection( + marker: typeof DISPATCH_REJECTED_QUEUE_FULL | typeof DISPATCH_REJECTED_CODEX_QUEUE_FULL +): AgentJournalDispatchRejection { + return { reason: marker, rejection: { kind: 'queueFull' } } +} + /** True for the internal transport marker, false for a provider's own words. */ export function dispatchRejectionWasTransportWriteFailure( reason: string | null | undefined diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 3c16614cf45..18c26700d9a 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -103,6 +103,25 @@ describe('structured agent session status projection', () => { }) }) + it("forwards a status row's failure fact and drops one this build cannot place", () => { + const failure = { + kind: 'providerExited' as const, + detail: { text: 'stderr tail', audience: 'log' as const } + } + expect( + projectStructuredItemToNativeChat( + item('exit', 1, { kind: 'status', text: 'Stopped.', failure }) + )?.blocks[0] + ).toEqual({ type: 'text', text: 'Stopped.', failure }) + const future = item('future', 2, { kind: 'status', text: 'Stopped.' }) + // A newer host's kind reads as no fact, so the row keeps its text and nothing else. + Object.assign(future.body, { failure: { kind: 'futureKind' } }) + expect(projectStructuredItemToNativeChat(future)?.blocks[0]).toEqual({ + type: 'text', + text: 'Stopped.' + }) + }) + it('projects running, attention, and completed lifecycle states', () => { const running = item('running', 1, { kind: 'status', diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 6e61603c758..a0d27930727 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -11,6 +11,7 @@ import { type AgentJournalTurnOutcome } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' import { readAgentJournalTurnOutcome } from './agent-session-turn-record' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, @@ -119,18 +120,7 @@ function itemBlocks(item: AgentJournalRenderItem): { if (body.kind !== 'status' || body.turnLifecycle) { return null } - return { - role: 'system', - blocks: [ - { - type: 'text', - text: body.text, - ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), - ...(body.tone !== undefined ? { tone: body.tone } : {}), - ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}) - } - ] - } + return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body)] } } function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode { diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts new file mode 100644 index 00000000000..58dbbe9131c --- /dev/null +++ b/src/shared/structured-agent-session-status-block.ts @@ -0,0 +1,18 @@ +import { readAgentSessionFailureFact } from './agent-session-failure' +import type { AgentJournalStatusItem } from './agent-session-journal-types' +import type { NativeChatTextBlock } from './native-chat-types' + +/** A status row as the line a chat paints: named fields only, so a host-only key never leaks. */ +export function structuredAgentSessionStatusBlock( + body: AgentJournalStatusItem +): NativeChatTextBlock { + const failure = readAgentSessionFailureFact(body.failure) + return { + type: 'text', + text: body.text, + ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), + ...(body.tone !== undefined ? { tone: body.tone } : {}), + ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}), + ...(failure ? { failure } : {}) + } +} From e25f4d8db0919bdc0059548d7b5a96536cd4d500 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:30:48 -0700 Subject: [PATCH 055/122] fix(native-chat): durable failure rows and rejection reasons are written for a person Every host writer that records a failure now writes a sentence for a person beside a typed fact, instead of embedding a refusal's message, an exception or a composed exit string. A provider's own words travel as a separate diagnostic from the places Orca composes them - the Claude and Codex exit stderr (a log), Codex's JSON-RPC message, Claude's compact_error and Codex's turn error (for a person) - and are never inferred from a string afterwards. Not signed in and oversized history are typed at the adapter that detects them. Covers start and restart failures, the delivery loop, dispatch rejections (content, queue-full, write failures, provider refusals), cancel and answer confirmation rows, compaction, the rewind fallback, and not_delivered, which released clients printed as it was. Two leaks close on the way: a settlement retry no longer writes Orca's probe evidence into the exit row, and an attach or journal-sink failure is recorded as Orca's fault rather than as the provider stopping. The legacy rejection markers and the reasons on sends in doubt stay byte-identical. --- .../claude/claude-stream-json-connection.ts | 8 +- .../claude/claude-structured-compaction.ts | 7 +- .../claude-structured-control-actions.test.ts | 6 +- ...aude-structured-dispatch-admission.test.ts | 6 +- .../claude-structured-dispatch-content.ts | 32 +++- .../claude/claude-structured-dispatch.test.ts | 24 ++- src/main/claude/claude-structured-dispatch.ts | 32 ++-- .../claude/claude-structured-init-proof.ts | 4 +- ...claude-structured-prompt-ownership.test.ts | 3 +- ...e-structured-session-adapter-turns.test.ts | 6 +- .../claude-structured-session-adapter.ts | 7 +- ...laude-structured-session-exit-lifecycle.ts | 7 + ...claude-structured-session-recovery.test.ts | 1 + ...claude-structured-session-startup-state.ts | 7 +- .../claude/claude-structured-session-state.ts | 5 +- src/main/codex/codex-app-server-exit-error.ts | 13 +- .../codex/codex-app-server-record-dispatch.ts | 3 +- .../codex/codex-app-server-request-error.ts | 13 +- ...odex-structured-dispatch-admission.test.ts | 17 +- .../codex/codex-structured-session-acquire.ts | 4 +- ...ructured-session-adapter-lifecycle.test.ts | 1 + .../codex-structured-session-adapter.test.ts | 7 +- .../codex/codex-structured-session-close.ts | 4 + src/main/codex/codex-structured-turn-start.ts | 20 ++- .../journal-crash-boundary.test.ts | 5 +- .../journal-dispatch-reason-bound.test.ts | 8 +- .../journal-pending-submission-recovery.ts | 9 +- .../journal-restart-reconciliation.test.ts | 7 +- .../journal-restart-reconciliation.ts | 3 +- .../journal-store-close.test.ts | 10 +- .../journal-store-contracts.ts | 2 +- .../agent-session-journal/journal-store.ts | 12 +- ...-agent-session-accept-then-deliver.test.ts | 60 +++++-- ...structured-agent-session-adapter-router.ts | 3 +- .../structured-agent-session-adapter.ts | 23 ++- ...ured-agent-session-append-delivery.test.ts | 3 +- ...ured-agent-session-attach-orchestration.ts | 3 + ...gent-session-attach-reconciliation.test.ts | 2 +- ...session-dead-generation-settlement.test.ts | 42 +++-- ...gent-session-dead-generation-settlement.ts | 89 +++------- .../structured-agent-session-delivery-loop.ts | 65 ++++---- ...structured-agent-session-event-recovery.ts | 3 + .../structured-agent-session-failure-text.ts | 157 ++++++++++++++++++ .../structured-agent-session-host-delivery.ts | 6 +- .../structured-agent-session-host-lifetime.ts | 4 +- ...structured-agent-session-host-mutations.ts | 13 +- .../structured-agent-session-host-types.ts | 3 + ...ured-agent-session-late-settlement.test.ts | 10 +- ...gent-session-provider-child-record.test.ts | 73 +++++--- ...red-agent-session-provider-restore.test.ts | 6 +- ...ed-agent-session-restart-ownership.test.ts | 8 +- ...red-agent-session-send-preparation.test.ts | 32 +++- ...ructured-agent-session-send-preparation.ts | 48 +----- ...session-send-restarts-failed-start.test.ts | 18 +- .../structured-agent-session-send.test.ts | 3 +- ...red-agent-session-settlement-retry.test.ts | 29 +++- ...ructured-agent-session-settlement-retry.ts | 4 +- ...uctured-agent-session-start-failure-row.ts | 32 +++- ...agent-session-startup-failure-exit.test.ts | 30 +++- ...red-agent-session-surface-lifetime.test.ts | 12 +- .../structured-agent-session-turns-prompt.ts | 11 +- .../structured-agent-session-turns.ts | 41 +++-- ...ured-agent-session-unexpected-exit.test.ts | 17 +- ...tructured-agent-session-unexpected-exit.ts | 5 +- .../structured-compaction-recovery.ts | 12 +- .../structured-conversation-command.test.ts | 24 ++- .../structured-conversation-command.ts | 64 +++++-- .../structured-rewind-journal-body.test.ts | 19 ++- .../structured-rewind-journal-body.ts | 10 +- .../structured-session-compaction.test.ts | 12 +- .../structured-session-compaction.ts | 29 +++- ...orchestration-structured-worker-session.ts | 4 +- .../structured-agent-session-hold.test.ts | 6 +- .../structured-claude-runtime-adapter.ts | 1 + .../agent-session-conversation-command.ts | 5 + ...ctured-agent-session-dispatch-rejection.ts | 7 + 76 files changed, 958 insertions(+), 383 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 1a2a65bca34..c1549402213 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' @@ -103,7 +104,12 @@ function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error) ? ` (code ${status.code})` : '' const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` - return cause ? new Error(message, { cause }) : new Error(message) + // Written for a log, not a person: the chat keeps it behind Details. + const diagnostic = providerDiagnostic([how.trim(), detail].filter(Boolean).join('\n'), 'log') + return withProviderDiagnostic( + cause ? new Error(message, { cause }) : new Error(message), + diagnostic + ) } export async function openClaudeStreamJsonConnection( diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts index 6c3155739c2..6e64eebbf13 100644 --- a/src/main/claude/claude-structured-compaction.ts +++ b/src/main/claude/claude-structured-compaction.ts @@ -1,5 +1,8 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import type { + StructuredSessionCompaction, + StructuredSessionCompactionResult +} from '../native-chat/agent-session-wire/structured-session-compaction' import { dispatchClaudeTurn } from './claude-structured-dispatch' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' /** Compaction needs no ack deadline of its own: `compactions.run` keeps its own @@ -9,7 +12,7 @@ export function compactClaudeSession( session: ClaudeSession, compactions: StructuredSessionCompaction, input: Parameters>[0] -): Promise<{ error?: string }> { +): Promise { return compactions.run( input.sessionId, session.providerSessionId, diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index f92a04a8003..77e57a8dca1 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -85,7 +85,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenNthCalledWith(1, { clientMessageId: 'client-0', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) @@ -118,7 +119,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenCalledWith({ clientMessageId: 'client-ambiguous', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index 9a4d60a824c..75553addd3c 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -139,7 +139,11 @@ describe('Claude structured dispatch admission', () => { clientMessageId: 'client-over-capacity', body: userMessage([{ type: 'text', text: 'one too many' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'claude structured dispatch queue is full' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'claude structured dispatch queue is full', + rejection: { kind: 'queueFull' } + }) expect(session.dispatchWaiters).toHaveLength(64) expect(session.connection.send).toHaveBeenCalledTimes(64) }) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 7df84091b7c..3e57ab1b8e3 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -5,6 +5,14 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal import type { NativeChatBlock } from '../../shared/native-chat-types' import { claudeRecord } from './claude-structured-item-translation' +/** Orca refused the message's content, as opposed to failing to read an attachment. */ +export class ClaudeDispatchContentError extends Error { + constructor(message: string) { + super(message) + this.name = 'ClaudeDispatchContentError' + } +} + const MAX_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_IMAGE_COUNT = 20 const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 @@ -19,10 +27,12 @@ export async function readClaudeImage(path: string, openImpl: typeof open = open const file = await openImpl(path, 'r') try { const invalidImage = (): Error => - new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`) + new ClaudeDispatchContentError( + `Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes` + ) const info = await file.stat() if (!info.isFile()) { - throw new Error('Claude image must be a file') + throw new ClaudeDispatchContentError('Claude image must be a file') } if (info.size > MAX_IMAGE_BYTES) { throw invalidImage() @@ -62,22 +72,28 @@ async function imageContent( ): Promise { budget.count += 1 if (budget.count > MAX_IMAGE_COUNT) { - throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`) + throw new ClaudeDispatchContentError( + `Claude messages support at most ${MAX_IMAGE_COUNT} images` + ) } if (block.url) { return { type: 'image', source: { type: 'url', url: block.url } } } if (!block.path) { - throw new Error('image reference has neither a path nor a URL') + throw new ClaudeDispatchContentError('image reference has neither a path nor a URL') } const data = await readClaudeImage(block.path) budget.localBytes += data.byteLength if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { - throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`) + throw new ClaudeDispatchContentError( + `Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes` + ) } const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] if (!mediaType) { - throw new Error(`Claude does not support the image type ${extname(block.path)}`) + throw new ClaudeDispatchContentError( + `Claude does not support the image type ${extname(block.path)}` + ) } return { type: 'image', @@ -99,7 +115,7 @@ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { - throw new Error('Claude dispatch accepts only user messages') + throw new ClaudeDispatchContentError('Claude dispatch accepts only user messages') } const images: unknown[] = [] const texts: string[] = [] @@ -115,7 +131,7 @@ export async function claudeDispatchMessageContent( // text blocks would silently discard every one but the last. const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { - throw new Error('Claude dispatch requires text or an image') + throw new ClaudeDispatchContentError('Claude dispatch requires text or an image') } return content } diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 1945ac3531d..758028440bf 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -379,7 +379,11 @@ describe('Claude structured dispatch image limits', () => { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed: broken pipe', + rejection: { kind: 'writeFailed' } + }) expect(session.dispatchWaiters).toEqual([firstWaiter]) const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid @@ -401,7 +405,11 @@ describe('Claude structured dispatch image limits', () => { await expect( dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed: broken pipe', + rejection: { kind: 'writeFailed' } + }) expect(session.dispatchWaiters).toHaveLength(0) expect(session.retiredDispatchWaiters).toHaveLength(0) @@ -695,7 +703,11 @@ describe('Claude structured dispatch image limits', () => { await expect( dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' }) + ).resolves.toEqual({ + state: 'rejected', + reason: "An attachment on this message can't be sent to the agent.", + rejection: { kind: 'attachmentInvalid' } + }) expect(session.connection.send).not.toHaveBeenCalled() }) @@ -716,7 +728,8 @@ describe('Claude structured dispatch image limits', () => { dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) ).resolves.toEqual({ state: 'rejected', - reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes` + reason: "An attachment on this message can't be sent to the agent.", + rejection: { kind: 'attachmentInvalid' } }) expect(session.connection.send).not.toHaveBeenCalled() } finally { @@ -736,7 +749,8 @@ describe('Claude structured dispatch image limits', () => { dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) ).resolves.toEqual({ state: 'rejected', - reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + reason: "An attachment on this message can't be sent to the agent.", + rejection: { kind: 'attachmentInvalid' } }) expect(session.connection.send).not.toHaveBeenCalled() } finally { diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 5a5b2796595..4ecb5813fb8 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -18,23 +18,27 @@ import type { } from './claude-structured-session-state' import { readClaudeFrameString } from './claude-structured-init-proof' import { + ClaudeDispatchContentError, claudeDispatchContentKey, claudeDispatchInvokesSlashCommand, claudeDispatchMessageContent } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { - DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL, - dispatchWriteFailureReason + DISPATCH_REJECTION_CANCELLED, + dispatchQueueFullRejection, + dispatchWriteFailureRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { claudeUnwrittenUserMessageError, claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { - claudeStartupFailureReason, + claudeStartupFailureFact, failClaudeStartup } from './claude-structured-session-startup-state' @@ -214,7 +218,7 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...DISPATCH_REJECTION_CANCELLED }) } } @@ -240,14 +244,22 @@ export async function dispatchClaudeTurn( try { content = await claudeDispatchMessageContent(input.body) } catch (error) { - return { state: 'rejected', reason: (error as Error).message } + // Orca's own refusal of the content, or an attachment it could not read; never the provider. + return { + state: 'rejected', + ...agentSessionFailureRejection( + agentSessionFailureFact( + error instanceof ClaudeDispatchContentError ? 'attachmentInvalid' : 'attachmentUnreadable' + ) + ) + } } if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { - return { state: 'rejected', reason: DISPATCH_REJECTED_QUEUE_FULL } + return { state: 'rejected', ...dispatchQueueFullRejection(DISPATCH_REJECTED_QUEUE_FULL) } } - const startupFailure = claudeStartupFailureReason(session) + const startupFailure = claudeStartupFailureFact(session) if (startupFailure) { - return { state: 'rejected', reason: startupFailure } + return { state: 'rejected', ...agentSessionFailureRejection(startupFailure) } } // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. @@ -293,7 +305,7 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...dispatchWriteFailureRejection(error) } } const waiter = replay.waiter if (waiter.settledUuid) { @@ -311,7 +323,7 @@ export async function dispatchClaudeTurn( waiter.resolve(null) // The frame was never handed to the SDK's input pump, so this is not doubt: // the message provably did not happen, which is what `rejected` means. - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...dispatchWriteFailureRejection(error) } } if (!waiter.retired) { retireWaiter(session, waiter) diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index c29cb2d4715..2a2487aad78 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -64,7 +64,9 @@ export function claudeInitializationAuthError( isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' ? new AgentSessionAcquisitionRefusal( - 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'agent_session_operation_invalid', + 'notSignedIn' ) : null } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 7a06a58e881..d53e5352899 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -269,7 +269,8 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', clientMessageId: 'queued-message', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index caafee6a94b..2479cd99cb1 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -68,7 +68,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed: broken pipe', + rejection: { kind: 'writeFailed' } + }) }) it('requires an acknowledged interrupt and supports controlled options', async () => { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6e90d1547bd..6f124f76a0e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import type { AgentSessionFailureFact } from '../../shared/agent-session-failure' import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' import type { AgentSessionAcquisition, @@ -13,7 +14,7 @@ import { supportsClaudeStructuredLocation } from './claude-structured-location-s import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { - claudeStartupFailureReason, + claudeStartupFailureFact, claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' @@ -119,13 +120,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda /** 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 => { + awaitStarted = async (sessionId: string): Promise => { const session = this.sessions.get(sessionId) if (!session) { return } await session.startup.settled - return claudeStartupFailureReason(session) ?? undefined + return claudeStartupFailureFact(session) ?? undefined } /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 7472c232e8f..7e119536eb3 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,3 +1,5 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, @@ -84,6 +86,11 @@ export function settleClaudeUnexpectedExit( type: 'ended', sessionId, reason: exit.error.message, + // A start that never landed says why it failed; a running child's exit keeps its stderr. + failure: + exit.session.startup.state === 'proven' + ? agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(exit.error) }) + : providerStartupFailureFact(exit.session.startup.failure ?? exit.error), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 8ddc29a2e67..a5647caee84 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -417,6 +417,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { type: 'ended', sessionId: 'session-1', reason: 'crashed before replacement', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: firstAcquisition.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index 1bda560377a..5717405a2c3 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -3,7 +3,8 @@ // 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 { AgentSessionFailureFact } from '../../shared/agent-session-failure' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import type { ClaudeSession } from './claude-structured-session-state' export type ClaudeSessionStartup = { @@ -24,9 +25,9 @@ export function createClaudeSessionStartup(): ClaudeSessionStartup { return { state: 'pending', failure: null, settled: ended, end } } -export function claudeStartupFailureReason(session: ClaudeSession): string | null { +export function claudeStartupFailureFact(session: ClaudeSession): AgentSessionFailureFact | null { return session.startup.state === 'failed' - ? providerStartupFailureRejection(session.startup.failure ?? undefined) + ? providerStartupFailureFact(session.startup.failure ?? undefined) : null } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 883e5285309..f2a27eb5e21 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,3 +1,5 @@ +import type { AgentJournalDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import type { AgentSessionFailureFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -64,6 +66,7 @@ export type ClaudeStructuredSessionEvent = type: 'ended' sessionId: string reason: string + failure?: AgentSessionFailureFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' fence?: number @@ -80,7 +83,7 @@ export type ClaudeLateDispatchOutcome = clientMessageId: string providerIdentity: AgentJournalItemIdentity } - | { clientMessageId: string; state: 'rejected'; reason: string } + | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { diff --git a/src/main/codex/codex-app-server-exit-error.ts b/src/main/codex/codex-app-server-exit-error.ts index 85d8bc7dd6e..8812e6698a0 100644 --- a/src/main/codex/codex-app-server-exit-error.ts +++ b/src/main/codex/codex-app-server-exit-error.ts @@ -2,6 +2,7 @@ // stderr tail is the only evidence: a CLI without the subcommand is a durable // capability fact, and anything else is this run's crash. +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { CodexAppServerUnsupportedError } from './codex-app-server-session' @@ -9,11 +10,17 @@ const EXIT_DETAIL_MAX_CHARS = 400 export function buildCodexAppServerExitError(stderrTail: string, cause?: Error): Error { const tail = stderrTail.trim().slice(0, EXIT_DETAIL_MAX_CHARS) + // The tail is Codex's own stderr: a log, kept behind Details. + const diagnostic = providerDiagnostic(tail, 'log') if (stderrIndicatesMissingAppServer(stderrTail)) { - return new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${tail}` + return withProviderDiagnostic( + new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${tail}` + ), + diagnostic ) } const detail = cause ? `: ${cause.message}` : tail ? `: ${tail}` : '' - return new Error(`codex app-server connection ended${detail}`) + const error = new Error(`codex app-server connection ended${detail}`) + return cause ? error : withProviderDiagnostic(error, diagnostic) } diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index e76f2509399..b1d3605b38f 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -88,7 +88,8 @@ export function createCodexAppServerRecordDispatcher(input: { : new CodexAppServerRequestError( waiter.method, typeof error.code === 'number' ? error.code : null, - `codex app-server ${waiter.method} failed: ${detail}` + `codex app-server ${waiter.method} failed: ${detail}`, + typeof error.message === 'string' ? error.message : undefined ) ) return diff --git a/src/main/codex/codex-app-server-request-error.ts b/src/main/codex/codex-app-server-request-error.ts index 0dbefaca965..0e7cc719017 100644 --- a/src/main/codex/codex-app-server-request-error.ts +++ b/src/main/codex/codex-app-server-request-error.ts @@ -1,12 +1,23 @@ +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' + /** Codex answered the call and refused it, rather than timing out or exiting. */ export class CodexAppServerRequestError extends Error { + /** Codex's own `error.message`, kept apart from the Orca text around it. */ + readonly providerDiagnostic?: ProviderDiagnostic + constructor( readonly method: string, readonly code: number | null, - message: string + message: string, + providerMessage?: string ) { super(message) this.name = 'CodexAppServerRequestError' + const diagnostic = + providerMessage === undefined ? undefined : providerDiagnostic(providerMessage, 'person') + if (diagnostic) { + this.providerDiagnostic = diagnostic + } } } diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 5fc811b9382..b8ea81d592c 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -140,7 +140,12 @@ describe('codex dispatch admission', () => { const { CodexAppServerRequestError } = await import('./codex-app-server-connection') const codex = fakeCodexAppServer({ 'turn/start': () => { - throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found') + throw new CodexAppServerRequestError( + 'turn/start', + -32602, + 'codex app-server turn/start failed: thread not found', + 'thread not found' + ) } }) const settlements: LateSettlement[] = [] @@ -148,9 +153,14 @@ describe('codex dispatch admission', () => { const connection = codex.connections[0]! startTurn(connection, 'turn-1') + // Codex's own words reach the sentence and the fact; Orca's prefix reaches neither. expect(await send(adapter, 'client-1')).toEqual({ state: 'rejected', - reason: 'thread not found' + reason: 'The provider did not accept this message: thread not found.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread not found', audience: 'person' } + } }) // A refused write is disarmed, so a later echo of that id settles nothing. @@ -347,7 +357,8 @@ describe('codex dispatch admission', () => { } expect(await send(adapter, 'client-overflow')).toEqual({ state: 'rejected', - reason: 'codex structured dispatch queue is full' + reason: 'codex structured dispatch queue is full', + rejection: { kind: 'queueFull' } }) echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 2ce13d21250..3c25d6f7e8f 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -199,7 +199,9 @@ export async function acquireCodexStructuredSession(input: { const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) if (restoreAdmission && !restoreAdmission.accepted) { throw new AgentSessionAcquisitionRefusal( - 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.', + 'agent_session_operation_invalid', + 'historyTooLarge' ) } const process = await codexProcessIdentity( diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index fc458304dc8..2c7258a2612 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -181,6 +181,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { type: 'ended', sessionId: 'session-1', reason: 'codex app-server connection ended', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: 'generation-1', diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index f17003f7e40..58d27c1aa02 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -452,7 +452,12 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { body: USER_MESSAGE, fence: 7 }) - ).toEqual({ state: 'rejected', reason: 'turn already running' }) + ).toEqual({ + // Built without Codex's own words, so nothing is quoted and no detail is invented. + state: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + }) }) it('rethrows a dead child so the wire settles the submission unknown', async () => { diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 1ed8dcd385c..43fba08ad24 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { @@ -29,6 +30,9 @@ export function handleCodexSessionExit(input: { type: 'ended', sessionId: input.sessionId, reason: input.error.message, + failure: agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(input.error) + }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 2d22ce42f3e..dd0aa544caf 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,3 +1,5 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { agentSessionFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -7,7 +9,10 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' +import { + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + dispatchQueueFullRejection +} from '../../shared/structured-agent-session-dispatch-rejection' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' // Writing a Codex turn and learning which message landed where, which are not @@ -128,13 +133,22 @@ export async function dispatchCodexTurn( ): Promise { try { if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { - return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL } + return { + state: 'rejected', + ...dispatchQueueFullRejection(DISPATCH_REJECTED_CODEX_QUEUE_FULL) + } } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { // Codex answered and declined, so no echo for this write can arrive. session.dispatchEchoes.disarm(input.clientMessageId) - return { state: 'rejected', reason: (error as Error).message } + // Codex's own words, when it gave any, are the one part of the error a person can use. + return { + state: 'rejected', + ...agentSessionFailureRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) + ) + } } // A timeout or transport failure can happen after the frame was written. // Keep the correlation armed so a later echo can prove delivery. diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index d142672f8e5..b2df8368512 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -16,7 +16,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' +import { dispatchWriteFailureRejection } from '../../../shared/structured-agent-session-dispatch-rejection' import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, @@ -180,7 +180,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_write_failed', state: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')), + ...dispatchWriteFailureRejection(new Error('broken pipe')), fence: 1 }) @@ -251,6 +251,7 @@ describe('crash between provider accept and journal commit', () => { clientMessageId: 'cm_1', state: 'rejected', reason: 'not_delivered', + rejection: { kind: 'notDelivered' }, fence: 2, recovered: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index 70052865242..b528d4c1b26 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -45,7 +45,13 @@ async function settle(reason: string): Promise { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, fence: 1 }) - await journal.resolveDispatch({ clientMessageId: 'msg-1', state: 'rejected', reason, fence: 1 }) + await journal.resolveDispatch({ + clientMessageId: 'msg-1', + state: 'rejected', + reason, + rejection: { kind: 'providerRejected' }, + fence: 1 + }) return journal.snapshot().submissions[0]?.reason ?? null } 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 c356d7904b2..e72d579871c 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,3 +1,4 @@ +import type { AgentJournalDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' 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' @@ -40,7 +41,7 @@ export async function markJournalPendingSubmissionsUnknown( export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, - reason: string + rejection: AgentJournalDispatchRejection ): Promise { const unwritten = journal .submissions() @@ -54,7 +55,7 @@ export async function rejectJournalPendingSubmissions( await journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'rejected', - reason, + ...rejection, fence, recovered: true }) @@ -66,7 +67,7 @@ export async function rejectJournalPendingSubmissions( export async function rejectJournalQueuedSubmissions( journal: AgentSessionJournal, fence: number, - reason: string, + rejection: AgentJournalDispatchRejection, which: (submission: AgentJournalSubmission) => boolean = () => true ): Promise { const queued = journal @@ -76,7 +77,7 @@ export async function rejectJournalQueuedSubmissions( await journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'rejected', - reason, + ...rejection, fence, recovered: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 46d161b1172..80ff17e5b8f 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -1,6 +1,7 @@ // Wiring the restart reconciler: what provider history is allowed to decide // about a submission the crash boundary could only doubt. +import { DISPATCH_REJECTION_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -122,7 +123,9 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(settled).toEqual(['cm_1']) const submission = journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + // A sentence, since released clients print the reason as it is, and the fact beside it. + expect(submission?.reason).toBe(DISPATCH_REJECTION_NOT_DELIVERED.reason) + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) }) it('leaves a submission unknown while the provider reports a turn in flight', async () => { @@ -254,7 +257,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { 'accepted', 'rejected' ]) - expect(restarted.submissions()[1]?.reason).toBe('not_delivered') + expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) }) it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { 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 f37571814bb..769b562a053 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 @@ -10,6 +10,7 @@ // through the user's Retry, which rotates the client message id; Orca still // never puts a message back on the wire on the user's behalf. +import { DISPATCH_REJECTION_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -116,7 +117,7 @@ export async function reconcileJournalSubmissionsAgainstHistory(input: { : { clientMessageId: outcome.clientMessageId, state: 'rejected', - reason: outcome.reason, + ...DISPATCH_REJECTION_NOT_DELIVERED, fence: input.fence, recovered: true } diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts index 960c65a638a..bc68652fc46 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts @@ -117,7 +117,15 @@ describe('closed-state admission happens at enqueue', () => { fence: 1 }) ), - settle(journal.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', fence: 1 })), + settle( + journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + reason: 'Not sent.', + rejection: { kind: 'hostFault' }, + fence: 1 + }) + ), settle( journal.appendLifecycleBatch({ settlementId: 'settle', 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 eb802b36fed..115398654b8 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 @@ -33,7 +33,7 @@ export type ResolveDispatchInput = { | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } | { state: 'pending' } /** `reason` is the sentence released clients print; `rejection` is what newer ones read. */ - | { state: 'rejected'; reason?: string | null; rejection?: AgentSessionFailureFact } + | { state: 'rejected'; reason: string; rejection: AgentSessionFailureFact } | { state: 'unknown'; reason?: string | null } ) 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 5119f7ef597..4b2e1c01cb6 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,5 +1,6 @@ // Append-only journal store for one agent session. +import type { AgentJournalDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -319,17 +320,20 @@ export class AgentSessionJournal { } /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ - async rejectPendingSubmissions(fence: number, reason: string): Promise { - return rejectJournalPendingSubmissions(this, fence, reason) + async rejectPendingSubmissions( + fence: number, + rejection: AgentJournalDispatchRejection + ): Promise { + return rejectJournalPendingSubmissions(this, fence, rejection) } /** Reject sends accepted but never handed over, optionally only those `which` names. */ async rejectQueuedSubmissions( fence: number, - reason: string, + rejection: AgentJournalDispatchRejection, which?: (submission: AgentJournalSubmission) => boolean ): Promise { - return rejectJournalQueuedSubmissions(this, fence, reason, which) + return rejectJournalQueuedSubmissions(this, fence, rejection, which) } /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, 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 186127847a6..8de78d5b335 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 @@ -2,6 +2,7 @@ // 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 type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -172,6 +173,14 @@ function errorRows(): string[] { ) } +function errorFailures(): (AgentSessionFailureFact | undefined)[] { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.failure] : [] + ) +} + let subscriptions = 0 function subscribe(): AgentSessionSubscribeEvent[] { @@ -309,9 +318,16 @@ describe('a start the chat needed and did not get', () => { 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] }) + // Orca's spawn error goes to the log; the row and every message say what failed, typed. + expect(rows[0]).toBe('The provider stopped before it finished starting.') + expect(errorFailures()).toEqual([{ kind: 'providerStartFailed' }]) + for (const id of [first, second]) { + expect(submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: rows[0], + rejection: { kind: 'providerStartFailed' } + }) + } const next = await accept('after the fix') await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) @@ -324,12 +340,21 @@ describe('a start the chat needed and did not get', () => { () => { adapterExtras = { supportsLocation: () => false } }, - 'cannot resume' + { + text: "Codex couldn't restart. Start a new chat to continue.", + failure: { + kind: 'restartFailed', + refusal: { code: 'structured_agent_session_unsupported' } + } + } ], [ 'spawn', () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), - 'spawn codex ENOENT' + { + text: 'The provider stopped before it finished starting.', + failure: { kind: 'providerStartFailed' } + } ], [ 'auth', @@ -337,9 +362,12 @@ describe('a start the chat needed and did not get', () => { acquire.mockRejectedValueOnce( new AgentSessionPreSpawnError(new Error('Not logged in. Please run /login.')) ), - 'Not logged in' + { + text: 'The provider stopped before it finished starting.', + failure: { kind: 'providerStartFailed' } + } ] - ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, cause) => { + ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { await host.close(SESSION) arrange() await host.flushAllStreamedEvents() @@ -348,8 +376,8 @@ describe('a start the chat needed and did not get', () => { const events = subscribe() await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(errorRows()).toHaveLength(1) - expect(errorRows()[0]).toContain(cause) + expect(errorRows()).toEqual([row.text]) + expect(errorFailures()).toEqual([row.failure]) const framedRows = events.flatMap((event) => event.type === 'batch' || event.type === 'snapshot' ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( @@ -380,11 +408,10 @@ describe('a start the chat needed and did not get', () => { 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') - }) + // The message names the start that failed, not a close or a restart it never met — and never + // the store's own error, which is Orca's and goes to the log. + expect(settled).toMatchObject({ dispatchState: 'rejected', rejection: errorFailures()[0] }) + expect(settled?.reason).not.toContain('record store write failed') expect(errorRows()).toEqual([settled?.reason]) }) }) @@ -481,7 +508,10 @@ describe('a child that exits before its message is handed over', () => { const id = await accept('hello') await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)?.reason).toContain('codex app-server crashed') + expect(submission(id)).toMatchObject({ + reason: 'The provider stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) 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 566e41a6635..3200e0ba2c0 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 @@ -1,3 +1,4 @@ +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionAccountHome, @@ -122,7 +123,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 f3e3252e4c3..02d3f9eb954 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 @@ -30,15 +30,22 @@ import type { AgentSessionThreadGoalChange, AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import { + isAgentSessionWireRefusalCode, + type AgentSessionRefusalCause +} from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { constructor( message: string, - readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid' + readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid', + /** The situation, so the chat can say what to do; the message is Orca's log wording. */ + readonly refusalCause?: AgentSessionRefusalCause ) { super(message) this.name = 'AgentSessionAcquisitionRefusal' @@ -116,14 +123,18 @@ export type AgentSessionDispatchOutcome = * anything and never promotes this to `unknown`. */ | { state: 'admitted' } - | { state: 'rejected'; reason: string } + | { state: 'rejected'; reason: string; rejection: AgentSessionFailureFact } /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } export type StructuredAgentSessionEndedEvent = { type: 'ended' sessionId: string + /** Log text only; the chat's words come from `failure`. */ reason: string + /** Why it ended, as the adapter knows it: the provider's exit with its own diagnostic, or an + * Orca fault. Absent reads as a provider exit with nothing to add. */ + failure?: AgentSessionFailureFact cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string @@ -224,8 +235,8 @@ export type StructuredAgentSessionAdapter = { turnId: string sessionId: string fence: number - onLateResult?: (result: { error?: string }) => Promise - }): Promise<{ error?: string }> + onLateResult?: (result: StructuredSessionCompactionResult) => Promise + }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill * a turn the client never asked to stop. */ cancelTurn(input: { @@ -281,7 +292,7 @@ export type StructuredAgentSessionAdapter = { /** Resolves once a session published before it proved its start has proven it, failed, or been * 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 + 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 9f38c85755f..6e794936953 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 @@ -85,6 +85,7 @@ function exitBeforeProof(): Promise { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, cause: 'unexpected-exit', startupUnproven: true }) @@ -158,7 +159,7 @@ 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/)]) + new Set(['The provider stopped before it finished starting.']) ) }) 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 743152b76b6..552333867cc 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 @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' import { recoverInterruptedCompaction } from './structured-compaction-recovery' // The host's attach, lifted out of the host class. @@ -264,6 +265,8 @@ function endReleasedChild( fence: child.fence, cause: 'attach-failed', reason: cause instanceof Error ? cause.message : String(cause), + // Orca failed to attach; the provider said nothing. + failure: agentSessionFailureFact('hostFault'), duringStartup: child.phase === 'starting', ...verdict }) 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 4e15b23d4d3..70e14a9a507 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 @@ -112,7 +112,7 @@ describe('attachJournal restart reconciliation', () => { expect(attached.unconfirmedClientMessageIds).toEqual([]) const submission = attached.journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) // Deciding is not sending: nothing here puts the message back on the wire. expect(dispatch).not.toHaveBeenCalled() }) 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 8ad51871c24..fc5f72ca0c9 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 @@ -4,11 +4,14 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { dispatchRejectionReasonIsInternal } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic +} from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { captureUnfinishedStructuredAgentSessionWork, - MAX_UNEXPECTED_EXIT_REASON_CHARS, settleStructuredAgentSessionDeadGeneration, UNEXPECTED_PROVIDER_EXIT_OUTCOME, unfinishedStructuredAgentSessionWorkWasInterrupted @@ -143,7 +146,7 @@ describe('dead structured-session generation settlement', () => { ).toHaveLength(1) }) - it('keeps the actionable tail when the provider dumps a stderr wall into its exit reason', async () => { + it('keeps a stderr wall out of the sentence, as a bounded detail for a log', async () => { await seedUnfinishedWork() await expect( @@ -155,19 +158,20 @@ describe('dead structured-session generation settlement', () => { pendingSubmissionReason: 'provider_exited_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: true, - unexpectedExitReason: 'stack frame '.repeat(4_000) + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('stack frame '.repeat(4_000), 'log') + }) }) ).resolves.toBe(true) const statuses = journal .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) expect(statuses).toHaveLength(1) - // The cause is bounded before composing, so the row never reaches the byte cap that would - // truncate the sentence telling the user the conversation is still usable. - expect(statuses[0]).toContain('stack frame') - expect(statuses[0]).toMatch(/You can continue in this conversation\.$/) - expect(statuses[0]?.length).toBeLessThan(MAX_UNEXPECTED_EXIT_REASON_CHARS * 2) + expect(statuses[0]?.text).toBe(UNEXPECTED_PROVIDER_EXIT_OUTCOME) + expect(statuses[0]?.failure?.kind).toBe('providerExited') + expect(statuses[0]?.failure?.detail?.audience).toBe('log') + expect(statuses[0]?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) }) it('retries an already settled expected close without writing through a closed journal gate', async () => { @@ -276,16 +280,24 @@ describe('dead structured-session generation settlement', () => { settlementId: `provider-exit:${SESSION}:7:generation-1`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, - unexpectedExitReason: 'claude stream-json exited (code 1): not signed in', + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('code 1\nnot signed in', 'log') + }), exitedDuringStartup: { generation: 'generation-1' } }) - const reason = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): not signed in.' + // The sentence is Orca's; the stderr the exit carried rides as a log detail only. expect(journal.submissions()).toEqual([ - expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', reason }) + expect.objectContaining({ + clientMessageId: 'client-held', + dispatchState: 'rejected', + reason: 'The provider stopped before it finished starting.', + rejection: { + kind: 'providerStartFailed', + detail: { text: 'code 1\nnot signed in', audience: 'log' } + } + }) ]) - expect(dispatchRejectionReasonIsInternal(reason)).toBe(false) }) it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { 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 f79cbfcbc6b..a977826caad 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 @@ -1,3 +1,7 @@ +import { + agentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, @@ -6,11 +10,12 @@ import type { import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { - boundJournalStatusText, - cancelledJournalPromptBody -} from '../agent-session-journal/journal-prompt-body-bounds' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + agentSessionFailureRejection, + startupFailureFromExit +} from './structured-agent-session-failure-text' import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' import { runningTurnLifecycleRevisions, @@ -20,65 +25,9 @@ import { export const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The provider stopped while this response was in progress. You can continue in this conversation.' -/** A provider may put a whole stderr dump in its exit reason; unbounded it would push the - * actionable tail past the row's byte cap and lose it to truncation. */ +/** Bounds the exit reason a settlement retry keeps as the lease's log evidence. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = 512 -/** The cause is the only thing separating an auth failure from an OOM kill, so it is carried - * into the copy rather than left in the durable record nothing renders. */ -export function unexpectedProviderExitOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped while this response was in progress: ${detail}. You can continue in this conversation.` - : UNEXPECTED_PROVIDER_EXIT_OUTCOME -} - -/** A restart that produced no child, answered to the send that asked for it; its cause is the - * whole story, and nothing is remembered, so the next try is a fresh one. A new chat is offered - * only when the host holds nothing this chat could restart from. */ -export function ownerRestartFailedOutcome(input: { - agentName: string - reason?: string - resumable: boolean -}): string { - const detail = exitReasonDetail(input.reason) - const failed = detail - ? `${input.agentName} couldn't restart: ${detail}.` - : `${input.agentName} couldn't restart.` - return input.resumable ? failed : `${failed} Start a new chat to continue.` -} - -/** A start that never finished has no response to interrupt; its cause is the whole story. */ -export function providerStartupFailureOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped before it finished starting: ${detail}.` - : '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 { - return providerStartupFailureOutcome( - cause === undefined ? undefined : cause instanceof Error ? cause.message : String(cause) - ) -} - -function exitReasonDetail(reason: string | undefined): string | undefined { - return reason - ?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - .trim() - .replace(/[.\s]+$/, '') -} - type DeadGenerationSubmission = Pick< ReturnType[number], 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' @@ -149,8 +98,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string showUnexpectedExitOutcome?: boolean - /** Why the provider stopped, when the host has it. Rendered with the outcome copy. */ - unexpectedExitReason?: string + /** Why the provider stopped, as the adapter told it: recorded beside the row, never in its text. */ + exitFailure?: AgentSessionFailureFact /** 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 } @@ -166,20 +115,23 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { // 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 + const startupFailure = input.exitedDuringStartup + ? startupFailureFromExit(input.exitFailure) + : null + await (startupFailure ? input.journal.rejectPendingSubmissions( input.fence, - providerStartupFailureRejection(input.unexpectedExitReason) + agentSessionFailureRejection(startupFailure) ) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome && input.exitedDuringStartup) { + if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { // 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) + startupFailure ) ) } else if (showUnexpectedExitOutcome) { @@ -188,7 +140,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { identity: { provider: 'orca', clientMessageId: input.settlementId }, body: { kind: 'status', - text: boundJournalStatusText(unexpectedProviderExitOutcome(input.unexpectedExitReason)) + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: input.exitFailure ?? agentSessionFailureFact('providerExited') } }) } 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 da6c341ce87..41058f28798 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 @@ -9,13 +9,18 @@ // 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 { + agentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../shared/agent-session-failure' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTION_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' + restartFailureFact, + startupFailureFromExit, + type AgentSessionFailureTextContext +} from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' import type { StructuredAgentSessionEndedChild, @@ -24,7 +29,8 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure + recordStructuredAgentSessionStartFailure, + type StructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' import { handOverSubmission } from './structured-agent-session-turns' @@ -38,8 +44,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { 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 + /** Who the chat's failure sentences name. */ + failureTextContext: (sessionId: string) => AgentSessionFailureTextContext onError: (sessionId: string, error: unknown) => void } @@ -50,7 +56,7 @@ type Prepared = | { ok: false; refusal: AgentSessionWireRefusal } | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } -type StartFailure = { startKey: string | null; text: string } +type StartFailure = Omit export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() @@ -87,8 +93,10 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - const text = this.deps.startFailureText(sessionId, prepared.refusal) - await this.deps.serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) + const failure = restartFailureFact(prepared.refusal) + await this.deps.serialize(sessionId, () => + this.fail(sessionId, { startKey: null, failure }) + ) return } // A child published before it proved its start takes no input yet; waited for outside @@ -102,13 +110,11 @@ export class StructuredAgentSessionDeliveryLoop { } } } catch (error) { + // The error is Orca's own and goes to the log; the chat says only that Orca failed. 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) - }) + const failure = agentSessionFailureFact('hostFault') await this.deps - .serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) + .serialize(sessionId, () => this.fail(sessionId, { startKey: null, failure })) .catch((failure: unknown) => { // Rows left queued are rejected by the next open, or by the next loop an accept wakes. this.running.delete(sessionId) @@ -125,7 +131,7 @@ export class StructuredAgentSessionDeliveryLoop { } await session.journal.rejectQueuedSubmissions( this.deps.conversationFence(sessionId), - DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTION_HOST_RESTARTED, // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) @@ -152,7 +158,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: string | null + startFailure: AgentSessionFailureFact | null ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -177,7 +183,9 @@ export class StructuredAgentSessionDeliveryLoop { } return this.fail(sessionId, { startKey: awaited?.generation ?? null, - text: ended ? endedChildRejection(ended) : (startFailure ?? providerStartupFailureOutcome()) + failure: ended + ? endedChildFailure(ended) + : (startFailure ?? agentSessionFailureFact('providerStartFailed')) }) } const next = oldestQueuedSubmission(session) @@ -202,7 +210,7 @@ export class StructuredAgentSessionDeliveryLoop { if (session) { await recordStructuredAgentSessionStartFailure( { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, - failure + { ...failure, context: this.deps.failureTextContext(sessionId) } ) } return this.stop(sessionId) @@ -232,18 +240,17 @@ function startThatFailedWhileQueued( ) { return null } - return { startKey: ended.generation, text: endedChildRejection(ended) } + return { startKey: ended.generation, failure: endedChildFailure(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. */ -function endedChildRejection(ended: StructuredAgentSessionEndedChild): string { +/** Why a queued message the child never took is rejected. The host stopping the child is Orca's + * cause, never the provider's. */ +function endedChildFailure(ended: StructuredAgentSessionEndedChild): AgentSessionFailureFact { if (ended.cause === 'host-stop') { - return ended.reason ?? HOST_STOPPED_BEFORE_DELIVERY + return agentSessionFailureFact('hostFault') + } + if (ended.duringStartup) { + return startupFailureFromExit(ended.failure) } - const reason = ended.reason ?? undefined - return ended.duringStartup - ? providerStartupFailureOutcome(reason) - : providerExitBeforeDeliveryRejection(reason) + return ended.failure ?? agentSessionFailureFact('providerExited') } 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 4905cc19257..96d91c2b37e 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 @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { stopAgentSessionProviderRoot, type StructuredAgentSessionLifecycleEvent @@ -57,6 +58,8 @@ export class StructuredAgentSessionEventRecovery { type: 'ended', sessionId, reason: `journal sink failure: ${error instanceof Error ? error.message : String(error)}`, + // Orca stopped the provider because its own journal failed. + failure: agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence, acquisitionGeneration diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts new file mode 100644 index 00000000000..f2570bf75b5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -0,0 +1,157 @@ +// The sentence the host persists beside each failure fact, and the facts a start or an exit +// reduces to. +// +// A row's `text` and a rejected message's `reason` are what every released client prints as they +// are, so they are written here for a person and never carry Orca's own error text: not a +// refusal's message, not an exception, not a probe's evidence. A provider's words reach the text +// only when the provider wrote them for a person; a log's never do. Newer clients choose their +// own copy from the fact. + +import { + agentSessionFailureFact, + providerDiagnosticOf, + type AgentSessionFailureFact, + type AgentSessionFailureKind, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { + agentSessionRefusalReference, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire-refusals' +import { + DISPATCH_REJECTION_NOT_DELIVERED, + type AgentJournalDispatchRejection +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' + +/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ +const MAX_QUOTED_DETAIL_CHARS = 512 + +/** + * Whether a refused start leaves the chat anything to start again from. `false`: 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 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 +} + +export type AgentSessionFailureTextContext = { + /** The chat's agent, when the writer knows it. */ + agentName?: string +} + +type Sentence = (context: AgentSessionFailureTextContext, fact: AgentSessionFailureFact) => string + +function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefined): string { + const quoted = + detail?.audience === 'person' + ? detail.text + .slice(0, MAX_QUOTED_DETAIL_CHARS) + .trim() + .replace(/[.\s]+$/, '') + : '' + return quoted ? `${lead}: ${quoted}.` : `${lead}.` +} + +const FAILURE_SENTENCES = { + providerStartFailed: () => 'The provider stopped before it finished starting.', + notSignedIn: ({ agentName }) => + `${agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then send your message again.`, + historyTooLarge: () => + "This conversation's history is too large to restore here. Start a new chat to continue.", + providerExited: () => 'The provider stopped before this message was sent.', + restartFailed: ({ agentName }, fact) => { + const failed = `${agentName ?? 'The agent'} couldn't restart.` + const code = fact.refusal?.code + return code && !START_REFUSAL_RESUMABLE[code] + ? `${failed} Start a new chat to continue.` + : failed + }, + providerRejected: (_, fact) => + quotingPersonDetail('The provider did not accept this message', fact.detail), + attachmentInvalid: () => "An attachment on this message can't be sent to the agent.", + attachmentUnreadable: () => + "An attachment on this message couldn't be read, so the message was not sent.", + queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.', + writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", + cancelled: () => 'This message was withdrawn before the agent started it.', + chatClosed: () => 'The chat closed before this message was sent.', + hostRestarted: () => 'Orca restarted before this message was sent.', + notDelivered: () => DISPATCH_REJECTION_NOT_DELIVERED.reason, + compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), + compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', + cancelUnconfirmed: () => 'Cancellation was not confirmed.', + answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', + hostFault: () => "Orca ran into a problem, so this didn't go through. Try again." +} satisfies Record + +/** The sentence a row or a rejected message records for this fact. */ +export function agentSessionFailureText( + fact: AgentSessionFailureFact, + context: AgentSessionFailureTextContext = {} +): string { + const sentence: Sentence = FAILURE_SENTENCES[fact.kind] + return sentence(context, fact) +} + +/** A rejection whose reason is the fact's sentence. The legacy markers are written by their own + * constants, never through here. */ +export function agentSessionFailureRejection( + fact: AgentSessionFailureFact, + context: AgentSessionFailureTextContext = {} +): AgentJournalDispatchRejection { + return { reason: agentSessionFailureText(fact, context), rejection: fact } +} + +/** A start that did not land. A refusal the adapter typed keeps its situation; anything else is a + * start that failed, with the provider's diagnostic when the error carried one. */ +export function providerStartupFailureFact(cause?: unknown): AgentSessionFailureFact { + if ( + cause instanceof AgentSessionAcquisitionRefusal && + (cause.refusalCause === 'notSignedIn' || cause.refusalCause === 'historyTooLarge') + ) { + return agentSessionFailureFact(cause.refusalCause) + } + return agentSessionFailureFact('providerStartFailed', { detail: providerDiagnosticOf(cause) }) +} + +/** A child that ended before it proved its start: an exit is a start that failed, keeping the + * provider's diagnostic; an Orca fault or a typed start refusal stays what it was. */ +export function startupFailureFromExit( + failure: AgentSessionFailureFact | undefined +): AgentSessionFailureFact { + if (!failure || failure.kind === 'providerExited') { + return agentSessionFailureFact('providerStartFailed', { detail: failure?.detail }) + } + return failure +} + +/** What the chat records when the delivery loop could not make the session ready. */ +export function restartFailureFact(refusal: AgentSessionWireRefusal): AgentSessionFailureFact { + if (refusal.cause === 'notSignedIn' || refusal.cause === 'historyTooLarge') { + return agentSessionFailureFact(refusal.cause) + } + // A child that died starting reads as any start that died does. + if (refusal.ownerVerdict === 'exited' || refusal.cause === 'providerStartFailed') { + return agentSessionFailureFact('providerStartFailed') + } + return agentSessionFailureFact('restartFailed', { + refusal: agentSessionRefusalReference(refusal) + }) +} 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 41b83e77eae..9a5df02b659 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 @@ -16,7 +16,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionStartFailureText } from './structured-agent-session-send-preparation' +import { structuredAgentSessionFailureTextContext } from './structured-agent-session-send-preparation' import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' @@ -49,8 +49,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), - startFailureText: (sessionId, cause) => - structuredAgentSessionStartFailureText(deps.store.getRecord(sessionId), cause), + failureTextContext: (sessionId) => + structuredAgentSessionFailureTextContext(deps.store.getRecord(sessionId)), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) const adoptOpened = async ( 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 6b2b869548c..50bc6f560d7 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 @@ -7,7 +7,7 @@ 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 { DISPATCH_REJECTION_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, @@ -62,7 +62,7 @@ export async function abandonQueuedStructuredAgentSessionMessages( await journal .rejectQueuedSubmissions( structuredAgentSessionConversationFence(deps.store, sessionId), - DISPATCH_REJECTED_PROVIDER_CLOSED + DISPATCH_REJECTION_PROVIDER_CLOSED ) .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, 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 5f2e7bd7452..9984f1ee81e 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 @@ -21,7 +21,10 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + DISPATCH_REJECTION_CANCELLED, + type AgentJournalDispatchRejection +} from '../../../shared/structured-agent-session-dispatch-rejection' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { @@ -153,7 +156,7 @@ export function cancelStructuredAgentSessionTurn( // 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 + DISPATCH_REJECTION_CANCELLED ) const child = context.sessions.get(ctx.sessionId)?.child if (child?.phase === 'starting') { @@ -244,7 +247,10 @@ export async function settleStructuredAgentSessionLateDispatch( input: { sessionId: string clientMessageId: string - } & ({ providerIdentity: AgentJournalItemIdentity } | { state: 'rejected'; reason: string }) + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + ) ): Promise { const session = context.sessions.get(input.sessionId) if (!session) { @@ -264,6 +270,7 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: input.clientMessageId, state: 'rejected', reason: input.reason, + rejection: input.rejection, fence } ) 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 44611300c7c..0849e3af95b 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,3 +1,4 @@ +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' 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' @@ -61,6 +62,8 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi cause: StructuredAgentSessionChildEndCause /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ reason: string | null + /** What the chat records about this end; absent reads as a provider exit with no detail. */ + failure?: AgentSessionFailureFact duringStartup: boolean /** Where the conversation's journal stood when the child ended, to order the end against a * message's acceptance. */ 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 6bda0b66189..629899bb40a 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 @@ -3,7 +3,10 @@ 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 { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTION_CANCELLED +} from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -233,14 +236,15 @@ describe('settling a send the provider proves it received after the ack window', sessionId: SESSION, clientMessageId: params.envelope.clientOperationId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...DISPATCH_REJECTION_CANCELLED }) expect(submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } } ]) }) 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 5abe04e8552..c53c858a36b 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,7 +16,7 @@ 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 type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -164,14 +164,20 @@ function submission(id: string): AgentJournalSubmission | undefined { return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) } -function statusRows(): { itemId: string; text: string; tone?: string }[] { +function statusRows(): { + itemId: string + text: string + tone?: string + failure?: AgentSessionFailureFact +}[] { 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 } : {}) + ...(item.body.tone ? { tone: item.body.tone } : {}), + ...(item.body.failure ? { failure: item.body.failure } : {}) } ] : [] @@ -192,6 +198,8 @@ function exit(child: ReturnType, reason: string, startupUnp type: 'ended', ...child, reason, + // As an adapter reports it: the exit's stderr as a log detail. + failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, cause: 'unexpected-exit', ...(startupUnproven ? { startupUnproven } : {}) }) @@ -320,14 +328,21 @@ describe('a settlement retry for an earlier child inside the attach for the next }) }) +const START_EXIT = 'claude stream-json exited (code 1)' +const START_TEXT = 'The provider stopped before it finished starting.' +const START_FAILURE: AgentSessionFailureFact = { + kind: 'providerStartFailed', + detail: { text: START_EXIT, audience: 'log' } +} + describe('a published child that dies while it proves its start', () => { - const EXIT = 'claude stream-json exited (code 1)' - const TEXT = providerStartupFailureOutcome(EXIT) + const EXIT = START_EXIT + const TEXT = START_TEXT 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() + const settled = deferred() adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } await restartHost() acquire.mockImplementation(spawnStartingChild) @@ -339,9 +354,9 @@ describe('a published child that dies while it proves its start', () => { if (order === 'the exit is processed first') { await exit(child, EXIT, true) - settled.resolve(TEXT) + settled.resolve(START_FAILURE) } else { - settled.resolve(TEXT) + settled.resolve(START_FAILURE) await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) await exit(child, EXIT, true) } @@ -351,11 +366,17 @@ describe('a published child that dies while it proves its start', () => { { itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, text: TEXT, - tone: 'error' + tone: 'error', + failure: START_FAILURE } ]) - expect(submission(first)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) - expect(submission(second)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) + for (const id of [first, second]) { + expect(submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: START_FAILURE + }) + } expect(rejectedIn(events, second)).toBe(true) expect(dispatch).not.toHaveBeenCalled() expect(acquire).toHaveBeenCalledTimes(2) @@ -364,11 +385,11 @@ 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) + const EXIT = START_EXIT + const TEXT = START_TEXT 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) } + adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) // Opening the tab: the view's hold starts a child that has not proven its start. @@ -394,7 +415,8 @@ describe("a view's start that dies while a sent message waits on it", () => { { itemId: `orca:${encodeURIComponent(`start-failure:${viewChild.acquisitionGeneration}`)}`, text: TEXT, - tone: 'error' + tone: 'error', + failure: START_FAILURE } ]) expect(rejectedIn(events, id)).toBe(true) @@ -467,9 +489,17 @@ describe('a child that ends before its message is handed over', () => { const events = subscribe() await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)?.reason).toContain('codex app-server crashed') + // The exit's stderr rides beside the sentence, never in it. + const failure = { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + } + expect(submission(id)).toMatchObject({ + reason: 'The provider stopped before this message was sent.', + rejection: failure + }) expect(statusRows()).toEqual([ - { itemId: expect.any(String), text: submission(id)?.reason, tone: 'error' } + { itemId: expect.any(String), text: submission(id)?.reason, tone: 'error', failure } ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) @@ -634,7 +664,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(statusRows()).toEqual([]) }) - it('fails the start after a host stop, with the stop as the reason (R2)', async () => { + it("fails the start after a host stop, as Orca's fault rather than the provider's (R2)", async () => { const reason = 'Claude never finished starting, so Orca stopped it.' const second = await stoppedWhileStarting(() => host['serialize'](SESSION, () => @@ -646,8 +676,11 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) - expect(submission(second)?.reason).toBe(reason) - expect(statusRows()).toEqual([{ itemId: expect.any(String), text: reason, tone: 'error' }]) + const text = "Orca ran into a problem, so this didn't go through. Try again." + expect(submission(second)).toMatchObject({ reason: text, rejection: { kind: 'hostFault' } }) + expect(statusRows()).toEqual([ + { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostFault' } } + ]) expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index c8a2e4bba14..da82e3e7859 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -34,7 +34,11 @@ function claudeAdapter(): StructuredAgentSessionAdapter { observedAt: HOST_TEST_NOW } }), - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + reason: 'unused', + rejection: { kind: 'providerRejected' } + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined 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 ca5ba68dee0..bbe3f518ba8 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 @@ -125,7 +125,7 @@ it('still continues a chat whose last send acquisition proves was never delivere const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(host.journalSnapshot(SESSION).submissions[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'not_delivered' + rejection: { kind: 'notDelivered' } }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) @@ -455,7 +455,11 @@ it('retains acquisition through slow continuation settlement, then releases it', await vi.advanceTimersByTimeAsync(GRACE * 2) expect(host.isHeld(SESSION)).toBe(true) expect(closeSession).not.toHaveBeenCalled() - settlement.resolve({ state: 'rejected', reason: 'provider refused' }) + settlement.resolve({ + state: 'rejected', + reason: 'provider refused', + rejection: { kind: 'providerRejected' } + }) expect((await continuing).continued).toMatchObject([{ outcome: 'refused' }]) expect(host.isHeld(SESSION)).toBe(false) await vi.advanceTimersByTimeAsync(GRACE) 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 baadfbe82d8..d6076d1f635 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 @@ -424,12 +424,16 @@ describe('a send with no live owner', () => { 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.' + // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. + const cause = 'The provider stopped before it finished starting.' const id = await accept(params) - expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'providerStartFailed' } + }) expect(dispatch).not.toHaveBeenCalled() // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. expect( @@ -491,8 +495,11 @@ describe('a send with no live owner', () => { expect(unresumable).toMatchObject({ dispatchState: 'rejected', - reason: - "Codex couldn't restart: This execution host cannot resume the requested structured agent session. Start a new chat to continue." + reason: "Codex couldn't restart. Start a new chat to continue.", + rejection: { + kind: 'restartFailed', + refusal: { code: 'structured_agent_session_unsupported' } + } }) }) @@ -508,8 +515,13 @@ describe('a send with no live owner', () => { const id = await accept(sendParams('owner being settled')) - const cause = "Codex couldn't restart: Another runtime is still adjudicating this lease." - expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) + // The refusal's prose stays out of the chat; its code rides in the fact. + const cause = "Codex couldn't restart." + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } + }) expect(acquire).not.toHaveBeenCalled() expect(errorStatuses()).toEqual([cause]) }) @@ -522,9 +534,11 @@ describe('a send with no live owner', () => { const id = await accept(sendParams('bookkeeping failed')) + // Orca's own fault: reported to the log, and the chat says only that Orca failed. expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - reason: "Codex couldn't restart: spawn-token mint failed." + reason: "Orca ran into a problem, so this didn't go through. Try again.", + rejection: { kind: 'hostFault' } }) expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) @@ -715,7 +729,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining('Not signed in') + rejection: { kind: 'providerStartFailed' } }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) const published = frames.slice(subscribed) 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 1fa923e971f..5c7490c357d 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 @@ -9,12 +9,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' -import { - ownerRestartFailedOutcome, - providerStartupFailureOutcome -} from './structured-agent-session-dead-generation-settlement' +import type { AgentSessionFailureTextContext } from './structured-agent-session-failure-text' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -22,29 +18,6 @@ import { } from './structured-agent-session-mutation-admission' import { rewindRefusal } from './structured-rewind-refusal' -/** - * 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 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. */ export function structuredAgentSessionSendBlock( record: AgentSessionRecord | null @@ -95,18 +68,9 @@ export async function openConversationForWrite( } } -/** 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) - } - return ownerRestartFailedOutcome({ - agentName: record ? TUI_AGENT_DISPLAY_NAMES[record.provider] : 'The agent', - reason: cause.message, - resumable: START_REFUSAL_RESUMABLE[cause.code] - }) +/** Who a failure sentence names: the chat's agent, when the record says. */ +export function structuredAgentSessionFailureTextContext( + record: AgentSessionRecord | null +): AgentSessionFailureTextContext { + return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} } 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 211d8684e15..a087ccf9d1b 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 @@ -97,11 +97,17 @@ function exitBeforeProof(): Promise { type: 'ended', ...currentChild(), reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, cause: 'unexpected-exit', startupUnproven: true }) } +const STARTUP_FAILURE = { + kind: 'providerStartFailed', + detail: { text: EXIT_REASON, audience: 'log' } +} + function journalStatuses(): string[] { return host .journalSnapshot(SESSION) @@ -197,14 +203,15 @@ describe('a send into a published session whose child ended before startup', () // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. expect(submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'The provider stopped before it finished starting.', + rejection: STARTUP_FAILURE, recovered: true }) expect( host.journalSnapshot(SESSION).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) expect(journalStatuses().slice(rowsBefore)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + 'The provider stopped before it finished starting.' ]) // The failed restart moved the fence twice: the acquisition, and the exit that released it. expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) @@ -241,13 +248,12 @@ describe('a send while the child of the first start is still proving itself', () expect(submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'The provider stopped before it finished starting.', + rejection: STARTUP_FAILURE, recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) - ]) + expect(journalStatuses()).toEqual(['The provider stopped before it finished starting.']) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index 1a6e5606c1c..f00e2386ca3 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 @@ -176,7 +176,8 @@ describe('send', () => { dispatch .mockImplementationOnce(async () => ({ state: 'rejected' as const, - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed: broken pipe', + rejection: { kind: 'writeFailed' as const } })) .mockImplementationOnce(async () => accepted()) const body = hostTestMessage('never written') 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 4ea4b4e13a3..1eb2bbcd86f 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 @@ -13,6 +13,7 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record. import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' +import { UNEXPECTED_PROVIDER_EXIT_OUTCOME } from './structured-agent-session-dead-generation-settlement' import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' const SESSION = 'session-alpha-1' @@ -174,7 +175,30 @@ describe('pending settlement retry', () => { expect(statusTexts()).toEqual([]) }) - it('writes user-facing copy carrying the cause when the exit was observed', async () => { + // The lease's evidence is Orca's probe text ("observed process exit"), never the provider's. + it.each(['transport closed', 'observed process exit'])( + 'writes user-facing copy without the lease evidence %j when the exit was observed', + async (detail) => { + await seedRunningTurn() + + await expect( + retry(`provider-exit:${SESSION}:${FENCE}:generation-1`, { + kind: 'exit-observed', + detail, + observedAt: 1_500 + }) + ).resolves.toBe(true) + + expect(statusTexts()).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) + expect(journal.snapshot().items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: { kind: 'providerExited' } + }) + } + ) + + it('marks the running turn interrupted at the observed exit', async () => { await seedRunningTurn() await expect( @@ -185,9 +209,6 @@ describe('pending settlement retry', () => { }) ).resolves.toBe(true) - expect(statusTexts()).toEqual([ - 'The provider stopped while this response was in progress: transport closed. You can continue in this conversation.' - ]) expect(journal.snapshot().items.map((item) => item.body)).toContainEqual( expect.objectContaining({ kind: 'turn', state: 'interrupted', completedAt: 1_500 }) ) 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 8d19420feb4..d75a857269d 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 @@ -77,9 +77,7 @@ export async function retryLoadedStructuredAgentSessionSettlement(input: { journal, verdict.completedAt ), - ...(record.lease.deathEvidence?.detail - ? { unexpectedExitReason: record.lease.deathEvidence.detail } - : {}), + // The lease's death evidence is Orca's probe text, so the row records the exit with no detail. onError }) if (!ok) { 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 00594966101..d2966d5d040 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,7 +1,20 @@ +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' 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' +import { + agentSessionFailureRejection, + agentSessionFailureText, + type AgentSessionFailureTextContext +} from './structured-agent-session-failure-text' + +/** A start that failed: keyed like its row, with what failed and whose words to use. */ +export type StructuredAgentSessionStartFailure = { + startKey: string | null + failure: AgentSessionFailureFact + context?: AgentSessionFailureTextContext +} /** * The one row a start that failed leaves in the chat, whoever saw it fail: an error row, so the @@ -11,12 +24,18 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi */ export function structuredAgentSessionStartFailureRow( startKey: string, - text: string + failure: AgentSessionFailureFact, + context: AgentSessionFailureTextContext = {} ): JournalLifecycleMutationInput { return { kind: 'item', identity: { provider: 'orca', clientMessageId: `start-failure:${startKey}` }, - body: { kind: 'status', text: boundJournalStatusText(text), tone: 'error' } + body: { + kind: 'status', + text: boundJournalStatusText(agentSessionFailureText(failure, context)), + tone: 'error', + failure + } } } @@ -27,7 +46,7 @@ export function structuredAgentSessionStartFailureRow( */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - failure: { startKey: string | null; text: string } + failure: StructuredAgentSessionStartFailure ): Promise { const oldest = oldestQueuedSubmission(session) if (!oldest) { @@ -38,9 +57,12 @@ export async function recordStructuredAgentSessionStartFailure( settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure.text)] + mutations: [structuredAgentSessionStartFailureRow(startKey, failure.failure, failure.context)] }) - await session.journal.rejectQueuedSubmissions(session.fence, failure.text) + await session.journal.rejectQueuedSubmissions( + session.fence, + agentSessionFailureRejection(failure.failure, failure.context) + ) } 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 88774c63bed..43750fbe04a 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 @@ -4,7 +4,6 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, @@ -13,7 +12,8 @@ import { const SESSION = 'session-1' const GENERATION = 'generation-1' -const REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' +const REASON = 'claude stream-json exited (code 1): session limit reached' +const STARTUP_TEXT = 'The provider stopped before it finished starting.' function startedSession(): StructuredAgentSessionUnexpectedExitSession & { journal: { appendLifecycleBatch: ReturnType } @@ -77,6 +77,8 @@ describe('a provider that ends before it finished starting', () => { const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { ...ended, + // The adapter typed the start's own failure; the host keeps it rather than reword it. + failure: { kind: 'notSignedIn' }, startupUnproven: true }) @@ -87,12 +89,16 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ // 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' } + body: { + kind: 'status', + text: 'The agent is not signed in for the selected account. Sign in, then send your message again.', + tone: 'error', + failure: { kind: 'notSignedIn' } + } }) ] }) ) - expect(providerStartupFailureOutcome(REASON)).toContain('not signed in') }) it('keeps an ordinary idle exit silent and resumable', async () => { @@ -110,7 +116,10 @@ describe('a provider that ends before it finished starting', () => { child: { generation: GENERATION, fence: 7, phase: 'starting' as const } } - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + ...ended, + failure: { kind: 'providerExited', detail: { text: REASON, audience: 'log' } } + }) expect(ticket).toBeNull() expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( @@ -119,7 +128,16 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ // 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' } + // The exit's stderr stays out of the sentence, as a log detail beside it. + body: { + kind: 'status', + text: STARTUP_TEXT, + tone: 'error', + failure: { + kind: 'providerStartFailed', + detail: { text: REASON, audience: 'log' } + } + } }) ] }) 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 5f768ee9b80..aad73fc8024 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 @@ -24,7 +24,7 @@ import { } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' +import { UNEXPECTED_PROVIDER_EXIT_OUTCOME } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { HOST_TEST_NOW as NOW, @@ -207,7 +207,11 @@ beforeEach(async () => { } }) closeSession = vi.fn(async () => true) - dispatch = vi.fn(async () => ({ state: 'rejected' as const, reason: 'unused' })) + dispatch = vi.fn(async () => ({ + state: 'rejected' as const, + reason: 'unused', + rejection: { kind: 'providerRejected' as const } + })) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost() }) @@ -708,11 +712,11 @@ describe('an unexpected provider exit', () => { const history = host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — carrying the cause, and never the old bare `Provider exited: ` row. + // written — its cause beside it, and never the old bare `Provider exited: ` row. const statuses = history.ok ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) : [] - expect(statuses).toEqual([unexpectedProviderExitOutcome('provider exited')]) + expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) dispatch.mockResolvedValueOnce({ 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 2f6a1a1f1f2..18d7a6aff06 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 @@ -1,3 +1,5 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureText } from './structured-agent-session-failure-text' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { decodeAgentSessionQuestionAnswers, @@ -82,14 +84,11 @@ export async function performPrompt( if (!committed.item) { throw error } + // The adapter's error is Orca's; the row says only what the user needs to know. + const failure = agentSessionFailureFact('answerUnconfirmed') await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, - { - kind: 'status', - text: `Your answer was recorded but the agent did not confirm it: ${ - error instanceof Error ? error.message : String(error) - }` - }, + { kind: 'status', text: agentSessionFailureText(failure), failure }, { fence: ctx.fence } ) } 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 5eac140868f..355a6c541f2 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 @@ -6,6 +6,10 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. +import { + agentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../shared/agent-session-failure' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -23,7 +27,11 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { providerStartupFailureRejection } from './structured-agent-session-dead-generation-settlement' +import { + agentSessionFailureRejection, + agentSessionFailureText, + providerStartupFailureFact +} from './structured-agent-session-failure-text' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' export { performSetOption } from './structured-agent-session-turns-options' @@ -76,7 +84,10 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - return { state: 'rejected', reason: providerStartupFailureRejection(error) } + return { + state: 'rejected', + ...agentSessionFailureRejection(providerStartupFailureFact(error)) + } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -85,11 +96,12 @@ async function dispatchSafely( async function appendStatus( ctx: AgentSessionTurnContext, clientMessageId: string, - text: string + text: string, + failure?: AgentSessionFailureFact ): Promise { await ctx.journal.appendItem( { provider: 'orca', clientMessageId }, - { kind: 'status', text }, + { kind: 'status', text, ...(failure ? { failure } : {}) }, { fence: ctx.fence } ) } @@ -157,7 +169,7 @@ export async function handOverSubmission( await ctx.journal.resolveDispatch({ clientMessageId, state: 'rejected', - reason: 'The message could not be read back and was not sent.', + ...agentSessionFailureRejection(agentSessionFailureFact('hostFault')), fence: ctx.fence }) return @@ -179,7 +191,15 @@ export async function handOverSubmission( providerIdentity: outcome.providerIdentity, fence: ctx.fence } - : { clientMessageId, state: outcome.state, reason: outcome.reason, fence: ctx.fence } + : outcome.state === 'rejected' + ? { + clientMessageId, + state: 'rejected', + reason: outcome.reason, + rejection: outcome.rejection, + fence: ctx.fence + } + : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } ) } catch (error) { // A failed resolution must not strand a pending row; an unknown result is @@ -229,6 +249,7 @@ export async function performCancel( } let cancelled = false let note = 'Cancellation requested.' + let failure: AgentSessionFailureFact | undefined try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) cancelled = input.scope @@ -257,9 +278,9 @@ export async function performCancel( if (input.prompt) { throw error } - note = `Cancellation was not confirmed: ${ - error instanceof Error ? error.message : String(error) - }` + // The adapter's error is Orca's; the row says only that the stop is unconfirmed. + failure = agentSessionFailureFact('cancelUnconfirmed') + note = agentSessionFailureText(failure) } if (cancelled && input.prompt) { await ctx.flushStreamedEvents() @@ -268,6 +289,6 @@ export async function performCancel( return { ok: true, value: { turnId: input.turnId, cancelled } } } // Keyed by the operation id so a replayed cancel upserts one item, not two. - await appendStatus(ctx, input.clientOperationId, note) + await appendStatus(ctx, input.clientOperationId, note, failure) return { ok: true, value: { turnId: input.turnId, cancelled } } } 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 1dc71d3a70a..3475f4dc7a8 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 @@ -7,7 +7,7 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' +import { UNEXPECTED_PROVIDER_EXIT_OUTCOME } from './structured-agent-session-dead-generation-settlement' import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { isStructuredAgentSessionRecoveryTicketCurrent, @@ -268,7 +268,11 @@ describe('provider-exit recovery tickets', () => { provider: 'orca', clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}` }, - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: { kind: 'providerExited' } + } }, { kind: 'item', @@ -357,7 +361,8 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ body: { kind: 'status', - text: unexpectedProviderExitOutcome('provider exited after completing the turn') + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: { kind: 'providerExited' } } }) ]) @@ -406,7 +411,11 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: { kind: 'providerExited' } + } }) ] }) 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 4874716d8cc..5ed301295bf 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 @@ -79,6 +79,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< fence: child.fence, cause: 'exit', reason: unexpectedEvent.reason, + ...(unexpectedEvent.failure ? { failure: unexpectedEvent.failure } : {}), duringStartup: exitedDuringStartup, // The adapter publishes an exit only once it saw the root go, first-hand or proven. rootGone: true @@ -140,7 +141,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< ? { settlementRetry: { settlementId: stableSettlementId, - // Bare cause: the retry renders it, and `exit-observed` already says the rest. + // Log evidence for the lease; the retry's row never renders it. detail: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) } } @@ -213,7 +214,7 @@ async function retryUnexpectedExitSettlement(input: { verdict: input.verdict, pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, - unexpectedExitReason: input.event.reason, + ...(input.event.failure ? { exitFailure: input.event.failure } : {}), ...(input.exitedDuringStartup ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } : {}), 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 64f1ee652e7..e61d21d7c11 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 @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -27,12 +28,19 @@ export async function settleInterruptedCompaction( return } const error = 'Previous compaction completion could not be confirmed after session recovery.' + const failure = agentSessionFailureFact('compactionUnconfirmed') await journal.appendItem( { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', text: error }, + { kind: 'status', text: error, failure }, { fence } ) - const recovered = { ...command, phase: 'committed' as const, state: 'unknown' as const, error } + const recovered = { + ...command, + phase: 'committed' as const, + state: 'unknown' as const, + error, + failure + } await store.setConversationCommand(sessionId, fence, recovered) await store.recordOperationOutcome({ callerKey: command.callerKey, 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 37d8d4ee83d..63417b3f135 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 @@ -127,12 +127,32 @@ describe('host conversation commands', () => { }) it('reports provider compaction failure without a stuck lifecycle', async () => { - compact.mockResolvedValue({ error: 'Not enough messages to compact.' }) + const detail = { text: 'Not enough messages to compact.', audience: 'person' as const } + compact.mockResolvedValue({ error: 'Not enough messages to compact.', detail }) + const failure = { kind: 'compactionFailed', detail } expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, - value: { state: 'completed', error: 'Not enough messages to compact.' } + value: { + state: 'completed', + error: 'Compaction failed: Not enough messages to compact.', + failure + } }) expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.state).toBe('completed') + const rows = host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure + }) + }) + + it("keeps Orca's own compaction error out of the sentence", async () => { + compact.mockResolvedValue({ error: 'The provider exited during compaction.' }) + expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + ok: true, + value: { error: 'Compaction failed.', failure: { kind: 'compactionFailed' } } + }) }) it('keeps an unknown compaction from being executed again', 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 5894defc483..b3543b36ed5 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 @@ -19,6 +19,35 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { conversationCommandBlocked } from './structured-conversation-command-admission' +import { + agentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { agentSessionFailureText } from './structured-agent-session-failure-text' +import type { StructuredSessionCompactionResult } from './structured-session-compaction' + +/** A failed compaction, keeping only what the provider wrote for a person. */ +function compactionFailure( + result: StructuredSessionCompactionResult +): AgentSessionFailureFact | undefined { + return result.error === undefined + ? undefined + : agentSessionFailureFact('compactionFailed', { detail: result.detail }) +} + +function compactionStatusBody(failure: AgentSessionFailureFact | undefined) { + return failure + ? { kind: 'status' as const, text: agentSessionFailureText(failure), failure } + : { kind: 'status' as const, text: 'Conversation compacted.' } +} + +function compactionCommandFailure(failure: AgentSessionFailureFact | undefined): { + error?: string + failure?: AgentSessionFailureFact +} { + return failure ? { error: agentSessionFailureText(failure).slice(0, 4096), failure } : {} +} export type ConversationCommandParams = { envelope: AgentSessionMutationEnvelope @@ -113,7 +142,7 @@ export function runStructuredConversationCommand( ...(replacementSessionId ? { replacementSessionId } : {}) } await store.setConversationCommand(sessionId, ctx.fence, prepared) - let error: string | undefined + let failure: AgentSessionFailureFact | undefined if (command === 'clear' && replacementSessionId) { const attach: AgentSessionAttachParams = { envelope: { @@ -149,12 +178,16 @@ export function runStructuredConversationCommand( ) { throw new Error(acquired.refusal.message) } + // The refusal's message is Orca's log text; the result keeps its situation instead. const failed = { ...prepared, replacementSessionId: undefined, phase: 'committed' as const, state: 'completed' as const, - error: acquired.refusal.message.slice(0, 4096) + error: "The new conversation couldn't start.", + failure: agentSessionFailureFact('restartFailed', { + refusal: agentSessionRefusalReference(acquired.refusal) + }) } await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } @@ -177,7 +210,7 @@ export function runStructuredConversationCommand( { fence: ctx.fence } ) try { - error = ( + failure = compactionFailure( await ctx.adapter.compact({ turnId: `compact:${clientOperationId}`, sessionId, @@ -191,16 +224,15 @@ export function runStructuredConversationCommand( return } await host.flushStreamedEvents(sessionId) - await ctx.journal.appendItem( - identity, - { kind: 'status', text: result.error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) + const late = compactionFailure(result) + await ctx.journal.appendItem(identity, compactionStatusBody(late), { + fence: ctx.fence + }) await store.setConversationCommand(sessionId, ctx.fence, { ...prepared, phase: 'committed', state: 'completed', - ...(result.error ? { error: result.error.slice(0, 4096) } : {}) + ...compactionCommandFailure(late) }) await store.recordOperationOutcome({ callerKey: caller.callerKey, @@ -213,27 +245,25 @@ export function runStructuredConversationCommand( }) }) }) - ).error + ) await host.flushStreamedEvents(sessionId) } catch (cause) { await ctx.journal.appendItem( identity, - { kind: 'status', text: 'Compaction completion is unconfirmed.' }, + compactionStatusBody(agentSessionFailureFact('compactionUnconfirmed')), { fence: ctx.fence } ) throw cause } - await ctx.journal.appendItem( - identity, - { kind: 'status', text: error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) + await ctx.journal.appendItem(identity, compactionStatusBody(failure), { + fence: ctx.fence + }) } const completed = { ...prepared, phase: 'committed' as const, state: 'completed' as const, - ...(error ? { error: error.slice(0, 4096) } : {}) + ...compactionCommandFailure(failure) } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 6fa4d32293b..07059d5b8cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from 'vitest' import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind' import { restoreRewindJournalBody } from './structured-rewind-journal-body' +/** A row this build cannot place: still a row, never its stored JSON. */ +const UNPLACEABLE = { + kind: 'status', + text: 'Orca could not show this item after the rewind.', + failure: { kind: 'hostFault' } +} + describe('rewind recovery of newer durable records', () => { it('keeps an unknown message role and block readable without discarding the row', () => { expect( @@ -43,16 +50,13 @@ describe('rewind recovery of newer durable records', () => { input: { path: 'file' }, state: 'paused-by-provider' } - expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) }) + expect(restoreRewindJournalBody(body)).toEqual(UNPLACEABLE) const status = { kind: 'status' as const, text: 'state', turnLifecycle: { turnId: 'turn', state: 'future-state' } } - expect(restoreRewindJournalBody(status)).toEqual({ - kind: 'status', - text: JSON.stringify(status) - }) + expect(restoreRewindJournalBody(status)).toEqual(UNPLACEABLE) }) it.each(['interrupted', 'unverifiable'] as const)( 'keeps a %s turn and its recorded endpoints', @@ -82,10 +86,7 @@ describe('rewind recovery of newer durable records', () => { } expect(restoreRewindJournalBody(turn)).toEqual(turn) const unknown = { ...turn, state: 'future-state' } - expect(restoreRewindJournalBody(unknown)).toEqual({ - kind: 'status', - text: JSON.stringify(unknown) - }) + expect(restoreRewindJournalBody(unknown)).toEqual(UNPLACEABLE) }) it('keeps a known turn outcome across rewind recovery in both journal shapes', () => { const turn = { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index addc234e2a1..3e97ad5fbf0 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' import { AGENT_JOURNAL_TURN_LIFECYCLE_STATES, @@ -9,10 +10,15 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] -/** Unknown future values remain visible evidence, never invented turn or prompt state. */ +/** A row this build cannot place stays visible as a row, never invented turn or prompt state — + * and never as its stored JSON, which is Orca's record, not something a person reads. */ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { let normalized: unknown = body - const fallback = () => ({ kind: 'status', text: JSON.stringify(body) }) + const fallback = () => ({ + kind: 'status', + text: 'Orca could not show this item after the rewind.', + failure: agentSessionFailureFact('hostFault') + }) if (body.kind === 'message') { normalized = { ...body, diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts index 13a22b655b3..7ee5deb260b 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts @@ -41,7 +41,10 @@ describe('structured compaction lifecycle', () => { turn: { id: 'c', status: 'failed', error: { message: 'Unavailable' } } }) }) - ).resolves.toEqual({ error: 'Unavailable' }) + ).resolves.toEqual({ + error: 'Unavailable', + detail: { text: 'Unavailable', audience: 'person' } + }) }) it.each(['success', 'failed'])( @@ -63,7 +66,12 @@ describe('structured compaction lifecycle', () => { result: '' }) await expect(result).resolves.toEqual( - state === 'success' ? {} : { error: 'Not enough messages to compact.' } + state === 'success' + ? {} + : { + error: 'Not enough messages to compact.', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + } ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts index 328c283dc11..0a34324d6f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -1,10 +1,17 @@ +import { providerDiagnostic, type ProviderDiagnostic } from '../../../shared/agent-session-failure' + +/** `error` is Orca's account of a failed compaction; `detail` is the provider's own words, when it + * gave any. */ +export type StructuredSessionCompactionResult = { error?: string; detail?: ProviderDiagnostic } + type PendingCompaction = { identity: string commandTurnId?: string turnId?: string error?: string + detail?: ProviderDiagnostic compacted: boolean - finish: (result: { error?: string }) => void + finish: (result: StructuredSessionCompactionResult) => void } function record(value: unknown): Record { @@ -27,16 +34,16 @@ export class StructuredSessionCompaction { sessionId: string, identity: string, invoke: () => Promise, - onLateResult?: (result: { error?: string }) => Promise, + onLateResult?: (result: StructuredSessionCompactionResult) => Promise, commandTurnId?: string - ): Promise<{ error?: string }> { + ): Promise { if (this.pending.has(sessionId)) { throw new Error('Compaction is already running.') } let timer: ReturnType let expired = false - const completion = new Promise<{ error?: string }>((resolve, reject) => { - const finish = (result: { error?: string }) => { + const completion = new Promise((resolve, reject) => { + const finish = (result: StructuredSessionCompactionResult) => { this.pending.delete(sessionId) if (expired && onLateResult) { void onLateResult(result).catch((error) => @@ -107,10 +114,14 @@ export class StructuredSessionCompaction { } if (method === 'turn/completed' && turn.id === pending.turnId) { const error = record(turn.error).message + const detail = typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined pending.finish( turn.status === 'completed' && pending.compacted ? {} - : { error: typeof error === 'string' ? error : 'Compaction did not complete.' } + : { + error: typeof error === 'string' ? error : 'Compaction did not complete.', + ...(detail ? { detail } : {}) + } ) } } @@ -123,6 +134,10 @@ export class StructuredSessionCompaction { if (message.compact_result === 'failed') { pending.error = typeof message.compact_error === 'string' ? message.compact_error : 'Compaction failed.' + pending.detail = + typeof message.compact_error === 'string' + ? providerDiagnostic(message.compact_error, 'person') + : undefined } if (message.compact_result === 'success' || message.subtype === 'compact_boundary') { pending.compacted = true @@ -137,7 +152,7 @@ export class StructuredSessionCompaction { const error = pending.error ?? (pending.compacted ? undefined : 'Compaction was not confirmed by the provider.') - pending.finish(error ? { error } : {}) + pending.finish(error ? { error, ...(pending.detail ? { detail: pending.detail } : {}) } : {}) } } } 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 06f657924b1..8c7baf5ea83 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -276,7 +276,9 @@ export async function sendStructuredWorkerPreamble(args: { // pending receipt; only this one lets the caller retry knowing nothing landed. throw new OrchestrationError( 'dispatch_preamble_undelivered', - `The dispatch preamble was not delivered: ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was not delivered${ + submission.rejection ? ` (${submission.rejection.kind})` : '' + }: ${submission.reason ?? 'no reason given'}.` ) } // Only `accepted` is an acknowledgement — the same rule the mail lane already applies. A thrown diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index c3099d843ce..995d38c8872 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -82,7 +82,11 @@ beforeEach(async () => { // acquire that throws leaves an unverifiable owner nothing may replace. releaseAcquisition: vi.fn(async () => true), closeSession, - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + reason: 'unused', + rejection: { kind: 'providerRejected' } + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index e6ece3b3582..d358f70cc74 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -59,6 +59,7 @@ export function structuredClaudeLifecycleEvent( type: 'ended', sessionId: event.sessionId, reason: event.reason, + ...(event.failure ? { failure: event.failure } : {}), cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts index e1c038de934..4fbbe816672 100644 --- a/src/shared/agent-session-conversation-command.ts +++ b/src/shared/agent-session-conversation-command.ts @@ -1,10 +1,15 @@ +import type { AgentSessionFailureFact } from './agent-session-failure' + export type AgentSessionConversationCommand = 'clear' | 'compact' export type AgentSessionConversationCommandResult = { command: AgentSessionConversationCommand state: 'completed' | 'unknown' replacementSessionId?: string + /** A sentence for a person; released clients print it as it is. */ error?: string + /** What failed, typed; absent from older hosts. */ + failure?: AgentSessionFailureFact } export type AgentSessionConversationCommandRecord = AgentSessionConversationCommandResult & { diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 7b4e1574491..50ee98c8546 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -64,6 +64,13 @@ export const DISPATCH_REJECTION_PROVIDER_CLOSED: AgentJournalDispatchRejection = rejection: { kind: 'chatClosed' } } +/** Restart reconciliation proved the provider never took it. Released clients printed the old + * `not_delivered` marker as it was, so new rows carry a sentence instead. */ +export const DISPATCH_REJECTION_NOT_DELIVERED: AgentJournalDispatchRejection = { + reason: 'This message was not delivered. Send it again to continue.', + rejection: { kind: 'notDelivered' } +} + /** The marker keeps its Orca text after the prefix, as released clients expect; they hide it. */ export function dispatchWriteFailureRejection(error: unknown): AgentJournalDispatchRejection { return { reason: dispatchWriteFailureReason(error), rejection: { kind: 'writeFailed' } } From b2bb2a1ef335ff8c858263dd4a0fc9cc02e86207 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:13:44 -0700 Subject: [PATCH 056/122] feat(native-chat): refusals name their cause, and a failed start is worded in one place A refusal now carries an optional cause beside its code: one closed enum of the situations a chat write can meet, set at every emitter a structured-chat write reaches. Returned refusals build it with refuse(code, cause, message). Store and host paths that raised a bare Error(code) now throw AgentSessionRefusalError, whose message is still the code and which has no code property; the RPC error mapper handles it before any other passthrough, keeps today's wire code and message byte-identical, and adds { refusal: { code, cause } } to the error's data. The hold throws it, and restart-resume files the cause beside the unchanged reason. The operation ledger stores the cause beside the code, so a replay names the same situation as the first answer. The store fallback copy picks its words and cause by situation, so a stale replay or a moved lease no longer reads as a latched owner. Every failed start is worded by structuredAgentSessionStartFailure(cause, context), which returns the row sentence and the typed fact together; the delivery loop, the exit settlement and the dispatch that met a starting child all call it. Provider diagnostics are capped at the lease record's 512 characters wherever a fact is built. --- src/main/claude/claude-structured-options.ts | 3 +- ...-agent-session-accept-then-deliver.test.ts | 2 +- ...-agent-session-acquisition-options.test.ts | 12 +- ...structured-agent-session-adopted-import.ts | 26 +-- ...structured-agent-session-attach-failure.ts | 1 + .../structured-agent-session-attach-flow.ts | 11 +- ...ured-agent-session-attach-orchestration.ts | 12 +- .../structured-agent-session-attach.ts | 21 ++- ...tructured-agent-session-client-delivery.ts | 3 +- ...gent-session-dead-generation-settlement.ts | 22 +-- .../structured-agent-session-delivery-loop.ts | 44 ++--- ...ession-failed-create-owner-verdict.test.ts | 5 + ...red-agent-session-failed-create-refusal.ts | 44 ++++- ...uctured-agent-session-failure-text.test.ts | 151 ++++++++++++++++++ .../structured-agent-session-failure-text.ts | 53 +++++- .../structured-agent-session-hold-resume.ts | 34 +++- .../structured-agent-session-holds.ts | 14 +- .../structured-agent-session-host.test.ts | 1 + .../structured-agent-session-host.ts | 4 +- ...ctured-agent-session-mutation-admission.ts | 1 + ...ured-agent-session-operation-settlement.ts | 1 + .../structured-agent-session-option-error.ts | 6 +- .../structured-agent-session-prompt-state.ts | 13 +- ...ctured-agent-session-refusal-cause.test.ts | 119 ++++++++++++++ ...tructured-agent-session-refusal-message.ts | 58 ++++++- ...structured-agent-session-replay-outcome.ts | 38 +++-- ...ed-agent-session-restart-failure-ledger.ts | 2 + ...ed-agent-session-restart-ownership.test.ts | 18 +++ ...red-agent-session-restart-resume-runner.ts | 8 + ...ctured-agent-session-restart-resume-set.ts | 3 + .../structured-agent-session-reveal.ts | 5 +- ...red-agent-session-send-preparation.test.ts | 2 +- ...ructured-agent-session-send-preparation.ts | 33 ++-- ...uctured-agent-session-start-failure-row.ts | 30 ++-- ...agent-session-startup-failure-exit.test.ts | 2 +- ...ructured-agent-session-thread-goal.test.ts | 6 +- .../structured-agent-session-thread-goal.ts | 12 +- .../structured-agent-session-turns-options.ts | 4 +- .../structured-agent-session-turns-prompt.ts | 19 ++- .../structured-agent-session-turns.ts | 32 ++-- ...tructured-agent-session-unexpected-exit.ts | 5 + ...red-conversation-command-admission.test.ts | 8 +- ...ructured-conversation-command-admission.ts | 46 ++++-- ...uctured-conversation-command-controller.ts | 19 ++- .../structured-conversation-command.ts | 5 +- .../structured-rewind-refusal.ts | 1 + ...ructured-agent-session-history-adoption.ts | 8 +- ...ent-session-conversation-command-record.ts | 5 +- .../agent-session-lease-transitions.ts | 13 +- .../agent-session-operation-admission.test.ts | 6 +- .../agent-session-processless-reservation.ts | 7 +- ...gent-session-provider-handle-transition.ts | 3 +- .../runtime/agent-session-record-options.ts | 3 +- .../runtime/agent-session-record-store.ts | 9 +- .../agent-session-recovery-capsule-entries.ts | 14 +- .../agent-session-reservation-admission.ts | 23 +-- ...gent-session-surface-release-transition.ts | 3 +- ...e-structured-resumed-start-failure.test.ts | 21 ++- ...e-structured-send-held-for-startup.test.ts | 25 +-- ...-send-restart-dies-before-dispatch.test.ts | 32 ++-- ...ude-structured-session-integration.test.ts | 6 +- ...structured-agent-session-create-support.ts | 3 +- src/main/runtime/rpc/errors.test.ts | 46 ++++++ src/main/runtime/rpc/errors.ts | 29 ++++ .../rpc/methods/agent-launch-replay.test.ts | 3 +- .../structured-agent-session-create.ts | 10 +- .../methods/structured-agent-session-gate.ts | 15 +- ...uctured-agent-session-precommit-refusal.ts | 8 +- .../structured-agent-session-reveal.ts | 8 +- .../rpc/methods/structured-agent-session.ts | 10 +- ...ed-claude-scripted-runtime-test-support.ts | 7 +- src/shared/agent-session-failure.ts | 11 +- .../agent-session-lease-adjudication.test.ts | 33 ++-- .../agent-session-lease-adjudication.ts | 32 ++-- .../agent-session-mutation-envelope.test.ts | 8 +- src/shared/agent-session-mutation-envelope.ts | 52 +++--- .../agent-session-operation-ledger.test.ts | 21 ++- src/shared/agent-session-operation-ledger.ts | 43 ++++- 78 files changed, 1125 insertions(+), 351 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 0090cd2f039..a299d9d601d 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -51,7 +51,8 @@ export function setClaudeStructuredSessionOption( if (session.startup.state !== 'proven') { return Promise.reject( new AgentSessionOptionRejectedError( - 'Claude is still starting; options can be changed once it is ready.' + 'Claude is still starting; options can be changed once it is ready.', + 'providerStarting' ) ) } 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 8de78d5b335..d8b7b50af13 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 @@ -344,7 +344,7 @@ describe('a start the chat needed and did not get', () => { text: "Codex couldn't restart. Start a new chat to continue.", failure: { kind: 'restartFailed', - refusal: { code: 'structured_agent_session_unsupported' } + refusal: { code: 'structured_agent_session_unsupported', cause: 'hostUnsupported' } } } ], 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 d49637e3bb7..3d71656de46 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 @@ -430,7 +430,11 @@ describe('structured session acquisition options', () => { }) ).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + refusal: { + code: 'agent_session_operation_invalid', + cause: 'providerStartFailed', + message: 'model list unavailable' + } }) expect(releaseAcquisition).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() @@ -526,7 +530,11 @@ describe('structured session acquisition options', () => { await (exitProven && failurePoint !== 'journal' ? expect(failed).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: injected.message } + refusal: { + code: 'agent_session_operation_invalid', + cause: 'providerStartFailed', + message: injected.message + } }) : expect(failed).rejects.toThrow( exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' 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 7a85de6a473..411a1e1dee9 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,4 +1,9 @@ -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { + agentSessionRefusalError, + isAgentSessionRefusalError, + refuse, + 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 type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' @@ -18,10 +23,13 @@ export async function prepareAdoptedTranscript( } catch (error) { return { ok: false, - refusal: { - code: 'agent_session_identity_required', - message: error instanceof Error ? error.message : String(error) - } + refusal: isAgentSessionRefusalError(error) + ? error.refusal + : refuse( + 'agent_session_identity_required', + 'transcriptUnreadable', + error instanceof Error ? error.message : String(error) + ) } } } @@ -35,7 +43,7 @@ async function readAdoptedTranscript( return null } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'transcriptNotFound') } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, @@ -49,7 +57,7 @@ async function readAdoptedTranscript( throw new Error(prepared.error) } if (prepared.items.length === 0) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'transcriptUnreadable') } return prepared.items } @@ -81,7 +89,7 @@ async function applyAdoptedTranscript( return } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'transcriptNotFound') } const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, @@ -99,6 +107,6 @@ async function applyAdoptedTranscript( // `replaced: false` means the transcript decoded to nothing. The row promised a conversation and // the provider resumed one, so an empty journal here is a disagreement, not an empty chat. if (!imported.replaced) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'transcriptUnreadable') } } 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 6af8eb369d5..d4ae5bb3a40 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 @@ -35,6 +35,7 @@ export async function settlePostAcquisitionAttachFailure( outcome: { status: 'failed', code: 'agent_session_operation_invalid', + cause: 'hostFault', message: cause instanceof Error ? cause.message : String(cause) }, exitProof, 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 b0108fd6773..c726c6d1b2a 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 @@ -1,3 +1,4 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' import { failedAcquisitionRefusal, @@ -80,10 +81,11 @@ export async function performAttach( const { params, store } = input const unsupported = (): AgentSessionMutationResult => ({ ok: false, - refusal: { - code: 'structured_agent_session_unsupported', - message: 'This execution host cannot create the requested structured agent session.' - } + refusal: refuse( + 'structured_agent_session_unsupported', + 'hostUnsupported', + 'This execution host cannot create the requested structured agent session.' + ) }) const sessionId = params.envelope.sessionId const admitted = admitAttachOrRefuse(params) @@ -281,6 +283,7 @@ async function settleUnsupportedReservation( outcome: { status: 'failed', code: 'structured_agent_session_unsupported', + cause: 'hostUnsupported', message: 'Structured session support changed before the provider could start.' }, exitProof: 'processless', 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 552333867cc..02efc31ea77 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 @@ -1,3 +1,4 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' import { recoverInterruptedCompaction } from './structured-compaction-recovery' @@ -133,10 +134,13 @@ async function runAttach( }) ) if (!settled) { - return refuseAgentSessionMutation({ - code: 'agent_session_ownership_unknown', - message: 'The provider-exit terminal journal settlement is still pending; retry attach.' - }) + return refuseAgentSessionMutation( + refuse( + 'agent_session_ownership_unknown', + 'settlementPending', + 'The provider-exit terminal journal settlement is still pending; retry attach.' + ) + ) } const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => context.runtimeState.probeOwner(sessionId) 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 383aaaeb319..82f051a7b81 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 @@ -26,6 +26,8 @@ import type { import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { AGENT_SESSION_WIRE_REFUSAL_CODES, + AgentSessionRefusalError, + refuse, type AgentSessionMutationEnvelope, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode @@ -119,10 +121,11 @@ export function admitAttachOrRefuse( if (params.providerHandle && params.providerHandle.kind !== params.provider) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: `A ${params.provider} session requires a ${params.provider} provider handle.` - } + refusal: refuse( + 'agent_session_operation_invalid', + 'requestMalformed', + `A ${params.provider} session requires a ${params.provider} provider handle.` + ) } } const fingerprint = computeAgentSessionPayloadFingerprint({ @@ -338,12 +341,14 @@ export function classifyStoreFailure( throw error } const code = rawCode as AgentSessionWireRefusalCode + const emitted = error instanceof AgentSessionRefusalError ? error.refusal.cause : undefined + // Why: a latched session is exactly where a bare store code strands the user. + const told = structuredAgentSessionRefusalMessage(code, emitted, record) + const cause = told?.cause ?? emitted return { code, - // Why: a latched session is exactly where a bare store code strands the user. - message: - structuredAgentSessionRefusalMessage(code, record) ?? - `The session store refused this call: ${code}.`, + ...(cause ? { cause } : {}), + message: told?.message ?? `The session store refused this call: ${code}.`, ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) } } 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 7681e0f3436..a824c861ab9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -1,3 +1,4 @@ +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import type { @@ -85,7 +86,7 @@ export class StructuredAgentSessionClientDelivery { private requireJournal(sessionId: string): AgentSessionJournal { const journal = this.sessions.get(sessionId)?.journal if (!journal) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } return journal } 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 a977826caad..2106734f5af 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 @@ -1,5 +1,6 @@ import { agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, type AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -13,8 +14,8 @@ import type { JournalLifecycleMutationInput } from '../agent-session-journal/jou import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { - agentSessionFailureRejection, - startupFailureFromExit + structuredAgentSessionStartFailure, + type AgentSessionFailureTextContext } from './structured-agent-session-failure-text' import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' import { @@ -25,8 +26,9 @@ import { export const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The provider stopped while this response was in progress. You can continue in this conversation.' -/** Bounds the exit reason a settlement retry keeps as the lease's log evidence. */ -export const MAX_UNEXPECTED_EXIT_REASON_CHARS = 512 +/** Bounds the exit reason a settlement retry keeps as the lease's log evidence; a provider + * diagnostic is held to the same cap. */ +export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS type DeadGenerationSubmission = Pick< ReturnType[number], @@ -100,6 +102,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it: recorded beside the row, never in its text. */ exitFailure?: AgentSessionFailureFact + /** Who a failed start's sentence names. */ + failureTextContext?: AgentSessionFailureTextContext /** 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 } @@ -116,13 +120,13 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { // 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. const startupFailure = input.exitedDuringStartup - ? startupFailureFromExit(input.exitFailure) + ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null await (startupFailure - ? input.journal.rejectPendingSubmissions( - input.fence, - agentSessionFailureRejection(startupFailure) - ) + ? input.journal.rejectPendingSubmissions(input.fence, { + reason: startupFailure.text, + rejection: startupFailure.failure + }) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] 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 41058f28798..cddc9abe667 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 @@ -17,9 +17,9 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire import { DISPATCH_REJECTION_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { - restartFailureFact, - startupFailureFromExit, - type AgentSessionFailureTextContext + structuredAgentSessionStartFailure, + type AgentSessionFailureTextContext, + type StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' import type { @@ -29,8 +29,7 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure, - type StructuredAgentSessionStartFailure + recordStructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' import { handOverSubmission } from './structured-agent-session-turns' @@ -56,7 +55,8 @@ type Prepared = | { ok: false; refusal: AgentSessionWireRefusal } | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } -type StartFailure = Omit +/** A failed start before it is worded; `fail` words it once, through the one wording point. */ +type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() @@ -93,9 +93,9 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - const failure = restartFailureFact(prepared.refusal) + const cause = { refusal: prepared.refusal } await this.deps.serialize(sessionId, () => - this.fail(sessionId, { startKey: null, failure }) + this.fail(sessionId, { startKey: null, cause }) ) return } @@ -112,9 +112,9 @@ export class StructuredAgentSessionDeliveryLoop { } catch (error) { // The error is Orca's own and goes to the log; the chat says only that Orca failed. this.deps.onError(sessionId, error) - const failure = agentSessionFailureFact('hostFault') + const cause = { hostFault: true } as const await this.deps - .serialize(sessionId, () => this.fail(sessionId, { startKey: null, failure })) + .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { // Rows left queued are rejected by the next open, or by the next loop an accept wakes. this.running.delete(sessionId) @@ -183,9 +183,9 @@ export class StructuredAgentSessionDeliveryLoop { } return this.fail(sessionId, { startKey: awaited?.generation ?? null, - failure: ended + cause: ended ? endedChildFailure(ended) - : (startFailure ?? agentSessionFailureFact('providerStartFailed')) + : { failure: startFailure ?? agentSessionFailureFact('providerStartFailed') } }) } const next = oldestQueuedSubmission(session) @@ -210,7 +210,13 @@ export class StructuredAgentSessionDeliveryLoop { if (session) { await recordStructuredAgentSessionStartFailure( { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, - { ...failure, context: this.deps.failureTextContext(sessionId) } + { + startKey: failure.startKey, + ...structuredAgentSessionStartFailure( + failure.cause, + this.deps.failureTextContext(sessionId) + ) + } ) } return this.stop(sessionId) @@ -240,17 +246,19 @@ function startThatFailedWhileQueued( ) { return null } - return { startKey: ended.generation, failure: endedChildFailure(ended) } + return { startKey: ended.generation, cause: endedChildFailure(ended) } } /** Why a queued message the child never took is rejected. The host stopping the child is Orca's * cause, never the provider's. */ -function endedChildFailure(ended: StructuredAgentSessionEndedChild): AgentSessionFailureFact { +function endedChildFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause { if (ended.cause === 'host-stop') { - return agentSessionFailureFact('hostFault') + return { hostFault: true } } if (ended.duringStartup) { - return startupFailureFromExit(ended.failure) + return { exit: ended.failure } } - return ended.failure ?? agentSessionFailureFact('providerExited') + return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 3d064eed7e0..cc72d141530 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -70,8 +70,10 @@ describe('failed create owner verdict', () => { // The cleanup's release proves the whole tree gone: the common failed start. acquire.mockRejectedValueOnce(new Error(EXIT_REASON)) const first = hostTestAttachParams(null) + // The replay names the same cause as the first answer: the ledger kept it beside the code. const refusal = { code: 'agent_session_operation_invalid', + cause: 'providerStartFailed', message: EXIT_REASON, ownerVerdict: 'exited' } @@ -92,8 +94,10 @@ describe('failed create owner verdict', () => { new AgentSessionAcquisitionRootExitObservedError(new Error(EXIT_REASON)) ) const first = hostTestAttachParams(null) + // The replay names the same cause as the first answer: the ledger kept it beside the code. const refusal = { code: 'agent_session_operation_invalid', + cause: 'providerStartFailed', message: EXIT_REASON, ownerVerdict: 'exited' } @@ -115,6 +119,7 @@ describe('failed create owner verdict', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', + cause: 'providerStartFailed', message: 'not signed in', ownerVerdict: 'exited' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index a5e00fde53c..061f315d014 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -1,7 +1,10 @@ -import type { - AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + isAgentSessionRefusalError, + refuse, + type AgentSessionAttachResult, + type AgentSessionMutationResult, + type AgentSessionRefusalCause, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionOperationOutcome, @@ -24,7 +27,11 @@ export function failedAcquisitionSettlement(error: unknown): { outcome: Extract } { if (error instanceof AgentSessionAcquisitionExitUnprovenError) { - const outcome = { code: 'agent_session_ownership_unknown', message: error.message } + const outcome = { + code: 'agent_session_ownership_unknown', + cause: 'exitUnproven' as const, + message: error.message + } return { exitProof: 'unproven', outcome: { status: 'failed', ...outcome } } } const exitProof = isAgentSessionPreSpawnError(error) @@ -35,7 +42,25 @@ export function failedAcquisitionSettlement(error: unknown): { const message = error instanceof Error ? error.message : String(error) const code = error instanceof AgentSessionAcquisitionRefusal ? error.code : 'agent_session_operation_invalid' - return { exitProof, outcome: { status: 'failed', code, message } } + return { + exitProof, + outcome: { status: 'failed', code, cause: failedAcquisitionCause(error), message } + } +} + +/** The situation a failed acquisition stands for: what the adapter typed, a provider that died + * starting, a store refusal the error carried, or Orca's own fault. */ +function failedAcquisitionCause(error: unknown): AgentSessionRefusalCause { + if (error instanceof AgentSessionAcquisitionRefusal) { + return error.refusalCause ?? 'providerStartFailed' + } + if ( + error instanceof AgentSessionAcquisitionRootExitObservedError || + error instanceof AgentSessionAcquisitionExitProvenError + ) { + return 'providerStartFailed' + } + return (isAgentSessionRefusalError(error) && error.refusal.cause) || 'hostFault' } /** A failed acquisition answered as a refusal on the first call, in the shape its replay takes; @@ -44,7 +69,10 @@ export function failedAcquisitionRefusal( error: unknown ): { ok: false; refusal: AgentSessionWireRefusal } | null { if (error instanceof AgentSessionAcquisitionRefusal) { - return { ok: false, refusal: { code: error.code, message: error.message } } + return { + ok: false, + refusal: refuse(error.code, failedAcquisitionCause(error), error.message) + } } // A proven exit is a settled fact; its message is the provider's own diagnostic. if ( @@ -53,7 +81,7 @@ export function failedAcquisitionRefusal( ) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: refuse('agent_session_operation_invalid', 'providerStartFailed', error.message) } } return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts new file mode 100644 index 00000000000..56edff23a84 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_FAILURE_KINDS, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic, + withProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { AgentSessionRefusalError, refuse } from '../../../shared/agent-session-wire-refusals' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' +import { + agentSessionFailureText, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' + +/** What Orca's own error text looks like: a code, a marker, a uuid, a path, an exception. */ +const ORCA_INTERNAL = + /agent_session_|execution_owner|provider_[a-z_]+|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ + +describe('the sentence the host persists', () => { + it.each(AGENT_SESSION_FAILURE_KINDS)('for %s is written for a person', (kind) => { + const text = agentSessionFailureText( + { + kind, + // A log detail and a refusal ride with the fact; neither may reach the sentence. + detail: { + text: 'Error: ENOENT /Users/me/.claude/x agent_session_conflict', + audience: 'log' + }, + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + }, + { agentName: 'Claude' } + ) + expect(text).not.toMatch(ORCA_INTERNAL) + expect(text).toMatch(/[.]$/) + }) + + it('quotes a provider sentence written for a person, bounded, and never a log', () => { + expect( + agentSessionFailureText({ + kind: 'providerRejected', + detail: { text: 'thread not found.', audience: 'person' } + }) + ).toBe('The provider did not accept this message: thread not found.') + expect( + agentSessionFailureText({ + kind: 'providerRejected', + detail: { text: 'panic at main.rs:4', audience: 'log' } + }) + ).toBe('The provider did not accept this message.') + }) + + it('offers a new chat only when nothing here can restart this one', () => { + const text = (code: 'agent_session_identity_required' | 'agent_session_conflict') => + agentSessionFailureText({ kind: 'restartFailed', refusal: { code } }, { agentName: 'Codex' }) + expect(text('agent_session_identity_required')).toBe( + "Codex couldn't restart. Start a new chat to continue." + ) + expect(text('agent_session_conflict')).toBe("Codex couldn't restart.") + }) +}) + +describe('structuredAgentSessionStartFailure', () => { + it('keeps a provider diagnostic only when the error carried one', () => { + const carried = withProviderDiagnostic( + new Error('claude stream-json exited (code 1): boom'), + providerDiagnostic('code 1\nboom', 'log') + ) + expect(structuredAgentSessionStartFailure({ error: carried })).toEqual({ + text: 'The provider stopped before it finished starting.', + failure: { kind: 'providerStartFailed', detail: { text: 'code 1\nboom', audience: 'log' } } + }) + // Orca's own words, however provider-like, are never promoted to a detail. + expect( + structuredAgentSessionStartFailure({ error: new Error('Not logged in. Run /login.') }).failure + ).toEqual({ kind: 'providerStartFailed' }) + }) + + it('keeps a start refusal the adapter typed', () => { + const refusal = new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account.', + 'agent_session_operation_invalid', + 'notSignedIn' + ) + expect(structuredAgentSessionStartFailure({ error: refusal }, { agentName: 'Claude' })).toEqual( + { + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + failure: { kind: 'notSignedIn' } + } + ) + }) + + it('words a refused restart by its situation, never its message', () => { + const words = structuredAgentSessionStartFailure( + { + refusal: refuse( + 'agent_session_ownership_unknown', + 'ownerUnproven', + 'Orca cannot prove that process 4242 on host-1 has exited.' + ) + }, + { agentName: 'Claude' } + ) + expect(words).toEqual({ + text: "Claude couldn't restart.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } + } + }) + }) + + it("reads an exit before the start as a failed start, and an Orca fault as Orca's", () => { + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: 'stderr', audience: 'log' } } + }).failure + ).toEqual({ kind: 'providerStartFailed', detail: { text: 'stderr', audience: 'log' } }) + expect(structuredAgentSessionStartFailure({ exit: { kind: 'hostFault' } }).failure).toEqual({ + kind: 'hostFault' + }) + expect(structuredAgentSessionStartFailure({ hostFault: true }).text).not.toMatch(ORCA_INTERNAL) + }) + + it("holds any provider detail to the lease record's cap", () => { + expect(MAX_PROVIDER_DIAGNOSTIC_CHARS).toBe(512) + expect(MAX_UNEXPECTED_EXIT_REASON_CHARS).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + const long = 'x'.repeat(4_000) + // However the caller built the detail, the fact stores at most the cap. + const words = structuredAgentSessionStartFailure({ + diagnostic: { text: long, audience: 'log' } + }) + expect(words.failure.detail?.text).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: long, audience: 'log' } } + }).failure.detail?.text + ).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + }) + + it('reads a thrown refusal the same as a returned one', () => { + const thrown = new AgentSessionRefusalError( + refuse('agent_session_conflict', 'claimConflicted', 'Another process claims this session.') + ) + expect(thrown.message).toBe('agent_session_conflict') + expect(structuredAgentSessionStartFailure({ refusal: thrown.refusal }).failure).toEqual({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index f2570bf75b5..54615124021 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -133,7 +133,7 @@ export function providerStartupFailureFact(cause?: unknown): AgentSessionFailure /** A child that ended before it proved its start: an exit is a start that failed, keeping the * provider's diagnostic; an Orca fault or a typed start refusal stays what it was. */ -export function startupFailureFromExit( +function startupFailureFromExit( failure: AgentSessionFailureFact | undefined ): AgentSessionFailureFact { if (!failure || failure.kind === 'providerExited') { @@ -143,7 +143,7 @@ export function startupFailureFromExit( } /** What the chat records when the delivery loop could not make the session ready. */ -export function restartFailureFact(refusal: AgentSessionWireRefusal): AgentSessionFailureFact { +function restartFailureFact(refusal: AgentSessionWireRefusal): AgentSessionFailureFact { if (refusal.cause === 'notSignedIn' || refusal.cause === 'historyTooLarge') { return agentSessionFailureFact(refusal.cause) } @@ -155,3 +155,52 @@ export function restartFailureFact(refusal: AgentSessionWireRefusal): AgentSessi refusal: agentSessionRefusalReference(refusal) }) } + +/** Why a start the chat needed did not land, as the place that saw it knows it. */ +export type StructuredAgentSessionStartFailureCause = + /** The session could not be made ready. */ + | { refusal: AgentSessionWireRefusal } + /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ + | { error: unknown } + /** The child ended before it proved its start, as its ended event told it. */ + | { exit: AgentSessionFailureFact | undefined } + /** Only the provider's words are known; the caller names their audience. */ + | { diagnostic: ProviderDiagnostic | undefined } + /** Orca's own fault; its error belongs in the log. */ + | { hostFault: true } + /** Already typed where it was observed. */ + | { failure: AgentSessionFailureFact } + +export type StructuredAgentSessionStartFailureWords = { + text: string + failure: AgentSessionFailureFact +} + +function startFailureFact(cause: StructuredAgentSessionStartFailureCause): AgentSessionFailureFact { + if ('refusal' in cause) { + return restartFailureFact(cause.refusal) + } + if ('error' in cause) { + return providerStartupFailureFact(cause.error) + } + if ('exit' in cause) { + return startupFailureFromExit(cause.exit) + } + if ('diagnostic' in cause) { + return agentSessionFailureFact('providerStartFailed', { detail: cause.diagnostic }) + } + if ('hostFault' in cause) { + return agentSessionFailureFact('hostFault') + } + return cause.failure +} + +/** The one place a failed start is worded: the error row and every message it rejects carry this + * sentence and this fact, whichever writer saw the start fail. */ +export function structuredAgentSessionStartFailure( + cause: StructuredAgentSessionStartFailureCause, + context: AgentSessionFailureTextContext = {} +): StructuredAgentSessionStartFailureWords { + const failure = startFailureFact(cause) + return { text: agentSessionFailureText(failure, context), failure } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts index 9ea59e18487..49509f99f29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts @@ -7,12 +7,17 @@ // on. A write-capable hold must fail when acquisition is refused so the surface never mistakes a // readable journal for a live provider child. +import { + isAgentSessionRefusalCause, + isAgentSessionWireRefusalCode, + refuse, + type AgentSessionRefusalCause +} from '../../../shared/agent-session-wire-refusals' import type { AgentSessionAttachResult, AgentSessionMutationResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { attachStructuredAgentSessionUnderSerialize, @@ -49,11 +54,16 @@ export async function resumeHeldStructuredAgentSession(input: { await context.runtimeState.resolveRecovery(sessionId) const record = context.deps.store.getRecord(sessionId) if (!record) { - return refuse('agent_session_identity_required', 'No structured session exists by that id.') + return refuseResume( + 'agent_session_identity_required', + 'recordMissing', + 'No structured session exists by that id.' + ) } if (!adapterSupportsRecord(context.deps.adapter, record)) { - return refuse( + return refuseResume( 'structured_agent_session_unsupported', + 'hostUnsupported', 'This execution host cannot resume the requested structured agent session.' ) } @@ -63,14 +73,20 @@ export async function resumeHeldStructuredAgentSession(input: { ) if (!params) { return record.lease.unreconciled - ? refuse( + ? refuseResume( 'execution_owner_reconciling', + 'hostReconciling', 'This host has not yet adjudicated the session lease.' ) : record.lease.claimStatus === 'conflicted' - ? refuse('agent_session_conflict', 'Another process claims this session.') - : refuse( + ? refuseResume( + 'agent_session_conflict', + 'claimConflicted', + 'Another process claims this session.' + ) + : refuseResume( 'agent_session_ownership_unknown', + 'notResumable', 'The session lease is not one this host may resume.' ) } @@ -115,6 +131,7 @@ function settledResumeRefusal( return failedCreateRefusal( { code: outcome.code, + ...(isAgentSessionRefusalCause(outcome.cause) ? { cause: outcome.cause } : {}), message: outcome.message ?? (error instanceof Error ? error.message : String(error)) }, outcome.status, @@ -122,9 +139,10 @@ function settledResumeRefusal( ) } -function refuse( +function refuseResume( code: AgentSessionWireRefusal['code'], + cause: AgentSessionRefusalCause, message: string ): StructuredAgentSessionResumeOutcome { - return { ok: false, refusal: { code, message } } + return { ok: false, refusal: refuse(code, cause, message) } } 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 0acbccb6b55..f4fe34766ba 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 @@ -16,6 +16,7 @@ // for one session would race against the same released fence, and the loser's stale fence refused // it — a hold that lost dropped its holder, a send that lost was refused. +import { AgentSessionRefusalError, refuse } from '../../../shared/agent-session-wire-refusals' import { StructuredAgentSessionReleaseClock, type StructuredAgentSessionReleaseClockDeps @@ -84,8 +85,8 @@ export class StructuredAgentSessionHolds { } if (!resumed.ok) { this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - // The RPC surface raises a refusal as its code. - throw new Error(resumed.refusal.code) + // The RPC surface raises a refusal as its code; the refusal itself rides along for its cause. + throw new AgentSessionRefusalError(resumed.refusal) } } @@ -126,10 +127,11 @@ export class StructuredAgentSessionHolds { if (!this.deps.hasProviderChild(sessionId)) { return { ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - message: 'The session attached without a provider child to write to.' - } + refusal: refuse( + 'agent_session_ownership_unknown', + 'noProviderChild', + 'The session attached without a provider child to write to.' + ) } } // The last surface can disconnect before acquisition makes a child available to release. 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 f8591926766..0c041804764 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 @@ -142,6 +142,7 @@ describe('attach', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', + cause: 'providerStartFailed', message: 'agent_session_provider_handle_stale_fence', ownerVerdict: 'exited' } 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 c35a0ed1cbb..609a1b8b06a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -6,7 +6,7 @@ import { StructuredConversationCommandController } from './structured-conversati import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type * as SessionWire from '../../../shared/agent-session-wire' +import * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' @@ -353,7 +353,7 @@ export class StructuredAgentSessionHost { private requireSession(sessionId: string): StructuredAgentSessionHostSession { const session = this.sessions.get(sessionId) if (!session) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + throw new SessionWire.AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } return session } 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 24f341d28da..6da1a81d658 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 @@ -33,6 +33,7 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' // transcript that failed to load; the two must never drift apart. export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + cause: 'sessionNotAttached', message: 'This host holds no attached session by that id.' } 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 5d5009aa918..5a152fe04f7 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 @@ -41,6 +41,7 @@ export async function runSettledAgentSessionMutation(input: { : { status: 'failed', code: outcome.refusal.code, + ...(outcome.refusal.cause ? { cause: outcome.refusal.cause } : {}), ...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {}) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts index 607db1e6c4b..d7c8803d471 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -1,6 +1,10 @@ /** Option validation failed before the provider session was mutated. */ export class AgentSessionOptionRejectedError extends Error { - constructor(cause: unknown) { + constructor( + cause: unknown, + /** `providerStarting`: nothing is wrong with the value; the session cannot take it yet. */ + readonly refusalCause: 'optionRejected' | 'providerStarting' = 'optionRejected' + ) { super(cause instanceof Error ? cause.message : String(cause), { cause }) this.name = 'AgentSessionOptionRejectedError' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 7f71a9c28ce..0685838a595 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemBody, AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionTurnContext } from './structured-agent-session-turns' type PendingPromptBody = Extract @@ -11,8 +11,9 @@ export type PendingPromptValidation = | { ok: true; item: AgentJournalRenderItem; prompt: PendingPromptBody } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): PendingPromptValidation { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +/** The prompt the client named is not one waiting on the user: nothing to answer. */ +function promptGone(message: string): PendingPromptValidation { + return { ok: false, refusal: refuse('agent_session_operation_invalid', 'promptGone', message) } } export function validatePendingPrompt( @@ -25,11 +26,11 @@ export function validatePendingPrompt( ): PendingPromptValidation { const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) if (!item) { - return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + return promptGone(`No item ${input.itemId} in session ${ctx.sessionId}.`) } const prompt = item.body.kind === 'approval' || item.body.kind === 'question' ? item.body : null if (!prompt || (input.kind !== undefined && prompt.kind !== input.kind)) { - return invalid( + return promptGone( `Item ${input.itemId} is not a pending${input.kind ? ` ${input.kind}` : ' prompt'}.` ) } @@ -38,6 +39,7 @@ export function validatePendingPrompt( ok: false, refusal: { code: 'agent_session_item_revision_stale', + cause: 'promptMoved', message: `Item ${input.itemId} has moved on.`, currentRevision: item.revision, resolution: prompt.resolution @@ -49,6 +51,7 @@ export function validatePendingPrompt( ok: false, refusal: { code: 'agent_session_already_resolved', + cause: 'promptAlreadyResolved', message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, currentRevision: item.revision, resolution: prompt.resolution diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts new file mode 100644 index 00000000000..f62086d3b76 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts @@ -0,0 +1,119 @@ +// A refusal's cause names the situation, so every place that answers for a refusal — the first +// reply, a ledger replay, the store fallback copy — must name the same one. + +import { describe, expect, it } from 'vitest' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { classifyStoreFailure } from './structured-agent-session-attach' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal +} from './structured-agent-session-adapter' +import { + failedAcquisitionRefusal, + failedAcquisitionSettlement +} from './structured-agent-session-failed-create-refusal' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' + +function replay(outcome: Parameters[0]['outcome']) { + return resolveAgentSessionReplayOutcome({ + operationId: 'op-1', + outcome, + reconstruct: () => null + }) +} + +describe('a ledger replay names the cause its first answer did', () => { + it.each([ + new AgentSessionAcquisitionRefusal( + 'not signed in', + 'agent_session_operation_invalid', + 'notSignedIn' + ), + new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')) + ])('for a failed create: %s', (error) => { + const first = failedAcquisitionRefusal(error) + const replayed = replay(failedAcquisitionSettlement(error).outcome) + expect(first?.refusal.cause).toBeDefined() + expect(replayed).toMatchObject({ + decision: 'refuse', + refusal: { code: first?.refusal.code, cause: first?.refusal.cause } + }) + }) + + it('for a create whose cleanup could not prove the child gone', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')) + ).outcome + expect(replay(outcome)).toMatchObject({ + refusal: { code: 'agent_session_ownership_unknown', cause: 'exitUnproven' } + }) + }) + + it('reads a row an older host wrote, with no cause, as naming none', () => { + const replayed = replay({ status: 'failed', code: 'agent_session_conflict' }) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('cause') + }) + + it('names a code this build cannot place as refused earlier', () => { + expect(replay({ status: 'failed', code: 'agent_session_future_code' })).toMatchObject({ + refusal: { code: 'agent_session_operation_invalid', cause: 'operationRefusedEarlier' } + }) + }) + + it('names a lost outcome and a lost result', () => { + expect(replay({ status: 'unknown' })).toMatchObject({ refusal: { cause: 'outcomeUnknown' } }) + expect(replay({ status: 'succeeded', sessionId: 's' })).toMatchObject({ + refusal: { cause: 'resultLost' } + }) + }) +}) + +describe('the store fallback copy', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-1' } + }) + ) + + it('words a situation its code would misdescribe by the situation', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', 'replaySuperseded'), + null, + record + ) + expect(refusal).toMatchObject({ + code: 'agent_session_ownership_unknown', + cause: 'replaySuperseded' + }) + // Not the latched-owner story: this owner is not in doubt, the replay is just stale. + expect(refusal.message).not.toContain('4242') + }) + + it('keeps the latched-owner story, with its cause, for an owner it cannot prove gone', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', 'ownerUnproven'), + null, + record + ) + expect(refusal.cause).toBe('ownerUnproven') + expect(refusal.message).toContain('4242') + }) + + it('names the latch for a bare code an older path still throws', () => { + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, { + ...record, + lease: { ...record.lease, claimStatus: 'conflicted' } + }).cause + ).toBe('claimConflicted') + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, null) + ).not.toHaveProperty('cause') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts index 0184366e344..ed9616bde67 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -8,7 +8,10 @@ */ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' +import type { + AgentSessionRefusalCause, + AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' function ownerDescription(record: AgentSessionRecord): string { const owner = record.lease.ownerProcess @@ -30,19 +33,64 @@ function latchedMessage(record: AgentSessionRecord): string { : 'Orca cannot tell whether an agent started for this session before the app stopped, so it will not start a second one on the same conversation. Quit any leftover agent process for this workspace and reopen this chat.' } -/** Null when the code has no session-specific story to tell; the caller keeps its own wording. */ +/** + * A situation whose own words the store's code would get wrong: each of these reaches the chat as + * `ownership_unknown` or `conflict`, which by code alone would read as the latched-owner story. + */ +const SITUATION_MESSAGES: Partial> = { + replaySuperseded: 'A newer start of this chat replaced this one. Try again.', + leaseMoved: 'This chat changed hands while Orca was starting it. Try again.', + spawnIdentityMismatch: + 'The agent that started was not the one Orca launched, so Orca did not use it. Try again.', + identityMismatch: + 'This chat belongs to a different workspace, agent or account. Start a new chat to continue.', + sessionExists: 'This chat already exists. Open it to continue.', + tabIdTaken: 'Another chat already uses this tab. Try again.', + conversationHeldElsewhere: + 'Another chat is already working in this conversation. Open that chat to continue.', + recordUnreadable: 'A newer version of Orca saved this chat. Update Orca to open it.' +} + +/** + * The words and the situation for a store refusal. `cause` is what the emitter named, when it named + * one; the latched branches name their own. Null when neither has a story to tell, and the caller + * keeps its own wording. + */ export function structuredAgentSessionRefusalMessage( code: AgentSessionWireRefusalCode, + cause: AgentSessionRefusalCause | undefined, record: AgentSessionRecord | null -): string | null { +): { message: string; cause: AgentSessionRefusalCause } | null { + const situational = cause ? SITUATION_MESSAGES[cause] : undefined + if (cause && situational) { + return { message: situational, cause } + } if (!record) { return null } if (code === 'agent_session_ownership_unknown' || code === 'agent_session_conflict') { - return latchedMessage(record) + return latchedRefusal(record, cause) } if (code === 'execution_owner_reconciling') { - return 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.' + return { + message: + 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.', + cause: 'hostReconciling' + } } return null } + +function latchedRefusal( + record: AgentSessionRecord, + cause: AgentSessionRefusalCause | undefined +): { message: string; cause: AgentSessionRefusalCause } { + const message = latchedMessage(record) + if (record.lease.settlementRetryRequired) { + return { message, cause: 'settlementPending' } + } + if (record.lease.claimStatus === 'conflicted') { + return { message, cause: 'claimConflicted' } + } + return { message, cause: cause === 'ownerAlive' ? 'ownerAlive' : 'ownerUnproven' } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index c81c45dfba5..0903a06fb36 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -1,9 +1,10 @@ import { rewindRefusal } from './structured-rewind-refusal' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + isAgentSessionRefusalCause, + isAgentSessionWireRefusalCode, + refuse, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export type AgentSessionReplayOutcomeDecision = @@ -23,13 +24,22 @@ export function resolveAgentSessionReplayOutcome(input: { if (outcome.rewindReason) { return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal } } - const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) - ? (outcome.code as AgentSessionWireRefusalCode) + const code = isAgentSessionWireRefusalCode(outcome.code) + ? outcome.code : 'agent_session_operation_invalid' + // The recorded situation, so a replay says what the first answer said; a code this build does + // not know was refused for a reason it cannot name. + const cause = + code !== outcome.code + ? 'operationRefusedEarlier' + : isAgentSessionRefusalCause(outcome.cause) + ? outcome.cause + : undefined return { decision: 'refuse', refusal: { code, + ...(cause ? { cause } : {}), message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` } } @@ -44,10 +54,11 @@ export function resolveAgentSessionReplayOutcome(input: { } return { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `The outcome of operation ${operationId} is unknown; it was not run again.` - } + refusal: refuse( + 'agent_session_operation_unknown', + 'outcomeUnknown', + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) } } const recorded = input.reconstruct() @@ -60,10 +71,11 @@ export function resolveAgentSessionReplayOutcome(input: { return outcome.status === 'succeeded' ? { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` - } + refusal: refuse( + 'agent_session_operation_unknown', + 'resultLost', + `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + ) } : { decision: 'rerun' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 3b8fad40de0..41b6085a721 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -184,6 +184,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failedAt: failure.failedAt, outcome: failure.outcome, reason: failure.reason, + ...(failure.cause ? { cause: failure.cause } : {}), retryable: deps.retryable(failure.marker) } ] @@ -255,6 +256,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { reason: resumed ? action.failureReason(outcome.sessionId) : (outcome.reason ?? 'agent_session_resume_refused'), + ...(!resumed && outcome.cause ? { cause: outcome.cause } : {}), latestPrompt: promptBySession.get(outcome.sessionId) ?? '', latestUserItemId: action.attempts.latestUserItemId(outcome.sessionId) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index bbe3f518ba8..4642d2ed1c5 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 @@ -713,3 +713,21 @@ it('logs teardown capsule publication failure and still releases the provider', warning.mockRestore() await rm(capsulePath, { recursive: true }) }) + +// The resume ledger's reason stays the refusal code, which is what every renderer's guidance keys +// on; the cause is filed beside it, never in its place. +it('files a restart refused by a conflicted claim under its code, with its cause beside it', async () => { + const { host, store } = await interruptedRestart() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, claimStatus: 'conflicted' } + })) + + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + await vi.waitFor(async () => + expect(await host.restartResume.listFailures()).toMatchObject([ + { reason: 'agent_session_conflict', cause: 'claimConflicted' } + ]) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 33a07e80335..6d4afec454b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -9,6 +9,10 @@ // Codex's thread id — which is native continuation. Nothing re-sends the user's prompt: that is // what makes an agent redo work it already finished. +import { + isAgentSessionRefusalError, + type AgentSessionRefusalCause +} from '../../../shared/agent-session-wire-refusals' import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' @@ -27,6 +31,8 @@ export type StructuredAgentSessionResumeOutcome = { outcome: 'resumed' | 'refused' /** Refusal code; `agent_session_resume_already_in_progress` names the live owner in `owner`. */ reason?: string + /** A thrown refusal's situation, kept apart so `reason` stays the code readers match. */ + cause?: AgentSessionRefusalCause owner?: string } @@ -123,10 +129,12 @@ async function resumeOne( } catch (error) { const reason = error instanceof Error ? error.message : String(error) const owner = resumeAdmissionOwner(error) + const cause = isAgentSessionRefusalError(error) ? error.refusal.cause : undefined return { sessionId, outcome: 'refused', reason, + ...(cause ? { cause } : {}), ...(owner === null ? {} : { owner }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 245fbcf4bbf..1293893e632 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -13,6 +13,7 @@ // that are not about work at all: the record still exists and this build supports it, and the // lease is free. +import type { AgentSessionRefusalCause } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionProviderHandleChainHead, @@ -57,6 +58,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa outcome: AgentSessionResumeFailureOutcome /** The host's or provider's refusal code, verbatim, so it can be quoted in a report. */ reason: string + /** The refusal's situation, when the host named one; absent on older records. */ + cause?: AgentSessionRefusalCause /** Whether naming it in an action would run it again: whether it is still an offer. A * continuation the chat already holds, or the user having moved on, makes a retry a no-op no * matter what the reason says. */ 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 8bf3bc2242c..7b2467d7a62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -11,6 +11,7 @@ // cannot read is not a refusal — a chat whose journal predates the SQLite store restores to nothing // here, yet attach still recovers it, so the tab is worth publishing either way. +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' @@ -30,10 +31,10 @@ export async function revealStructuredAgentSession( ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'recordMissing') } if (!adapterSupportsRecord(deps.adapter, record)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostUnsupported') } // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a // contested or reconciling chat still reveals and the hold that follows adjudicates it. Refusing 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 d6076d1f635..b5906b573c0 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 @@ -498,7 +498,7 @@ describe('a send with no live owner', () => { reason: "Codex couldn't restart. Start a new chat to continue.", rejection: { kind: 'restartFailed', - refusal: { code: 'structured_agent_session_unsupported' } + refusal: { code: 'structured_agent_session_unsupported', cause: 'hostUnsupported' } } }) }) 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 5c7490c357d..318afb6b18a 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 @@ -5,9 +5,10 @@ // or a journal that cannot be opened. import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import type { AgentSessionFailureTextContext } from './structured-agent-session-failure-text' @@ -34,12 +35,17 @@ export function structuredAgentSessionSendBlock( ) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: command.replacementSessionId - ? 'This conversation has been cleared. Use the current conversation.' - : 'The conversation operation is unconfirmed.' - } + refusal: command.replacementSessionId + ? refuse( + 'agent_session_operation_invalid', + 'conversationCleared', + 'This conversation has been cleared. Use the current conversation.' + ) + : refuse( + 'agent_session_operation_invalid', + 'conversationCommandUnconfirmed', + 'The conversation operation is unconfirmed.' + ) } } return null @@ -58,12 +64,13 @@ export async function openConversationForWrite( } catch (error) { return { ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - message: `The conversation could not be opened: ${ + refusal: refuse( + 'agent_session_journal_unreadable', + 'journalUnreadable', + `The conversation could not be opened: ${ error instanceof Error ? error.message : String(error) }` - } + ) } } } 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 d2966d5d040..3432e5915f6 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,19 +1,12 @@ -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' 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' -import { - agentSessionFailureRejection, - agentSessionFailureText, - type AgentSessionFailureTextContext -} from './structured-agent-session-failure-text' +import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' -/** A start that failed: keyed like its row, with what failed and whose words to use. */ -export type StructuredAgentSessionStartFailure = { +/** A start that failed, keyed like its row, in the words `structuredAgentSessionStartFailure` gave. */ +export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFailureWords & { startKey: string | null - failure: AgentSessionFailureFact - context?: AgentSessionFailureTextContext } /** @@ -24,17 +17,16 @@ export type StructuredAgentSessionStartFailure = { */ export function structuredAgentSessionStartFailureRow( startKey: string, - failure: AgentSessionFailureFact, - context: AgentSessionFailureTextContext = {} + words: StructuredAgentSessionStartFailureWords ): JournalLifecycleMutationInput { return { kind: 'item', identity: { provider: 'orca', clientMessageId: `start-failure:${startKey}` }, body: { kind: 'status', - text: boundJournalStatusText(agentSessionFailureText(failure, context)), + text: boundJournalStatusText(words.text), tone: 'error', - failure + failure: words.failure } } } @@ -57,12 +49,12 @@ export async function recordStructuredAgentSessionStartFailure( settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure.failure, failure.context)] + mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] + }) + await session.journal.rejectQueuedSubmissions(session.fence, { + reason: failure.text, + rejection: failure.failure }) - await session.journal.rejectQueuedSubmissions( - session.fence, - agentSessionFailureRejection(failure.failure, failure.context) - ) } 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 43750fbe04a..9cf76ee8448 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 @@ -91,7 +91,7 @@ describe('a provider that ends before it finished starting', () => { identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, body: { kind: 'status', - text: 'The agent is not signed in for the selected account. Sign in, then send your message again.', + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', tone: 'error', failure: { kind: 'notSignedIn' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 3d4e8491986..d8a7390e6c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -125,7 +125,11 @@ describe('performThreadGoalChange', () => { expect(result).toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'goals feature is disabled' } + refusal: { + code: 'agent_session_operation_invalid', + cause: 'providerRejected', + message: 'goals feature is disabled' + } }) expect(journal.snapshot().items).toEqual([]) }) 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 3f63679d55f..b98996606ff 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 @@ -1,6 +1,7 @@ // `agentSession.threadGoal`: change the provider thread's goal through the same // admission, ledger and journal path every other session mutation takes. +import { refuse, type AgentSessionRefusalCause } from '../../../shared/agent-session-wire-refusals' import type { AgentJournalItemIdentity, AgentJournalThreadGoal @@ -13,8 +14,11 @@ import type { import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -function refused(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function refused( + cause: AgentSessionRefusalCause, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', cause, message) } } /** Keyed by the operation, so a replayed set upserts its one objective row. */ @@ -44,7 +48,7 @@ export async function performThreadGoalChange( input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) { - return refused('Goals are unavailable for this chat session.') + return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input const identity = objectiveIdentity(input.clientOperationId) @@ -89,7 +93,7 @@ export async function performThreadGoalChange( } if (!result.ok) { await withdrawObjective() - return refused(result.rejected) + return refused('providerRejected', result.rejected) } return { ok: true, value: { change: change.kind } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index cb1685405a1..eaa1f896b7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -1,4 +1,4 @@ -import type { AgentSessionOptionResult } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -17,7 +17,7 @@ export async function performSetOption( if (isAgentSessionOptionRejectedError(error)) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: refuse('agent_session_operation_invalid', error.refusalCause, error.message) } } throw error 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 18d7a6aff06..7014540c49c 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 @@ -6,14 +6,18 @@ import { isValidAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import type { AgentJournalResolution } from '../../../shared/agent-session-journal-types' -import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' +import { + refuse, + type AgentSessionPromptResult, + type AgentSessionRefusalCause +} from '../../../shared/agent-session-wire' import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -function invalid(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function invalid(cause: AgentSessionRefusalCause, message: string): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', cause, message) } } export async function performPrompt( @@ -46,11 +50,14 @@ export async function performPrompt( !acceptsGrouped && !prompt.options.some((option) => option.id === input.optionId) ) { - return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) + return invalid( + 'optionRejected', + `Option ${input.optionId} is not offered by item ${input.itemId}.` + ) } const identity = parseAgentJournalItemKey(input.itemId) if (!identity) { - return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + return invalid('requestMalformed', `Item id ${input.itemId} is not a well-formed item key.`) } const resolution: AgentJournalResolution = { @@ -79,7 +86,7 @@ export async function performPrompt( }) } catch (error) { if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { - return invalid(error.message) + return invalid('promptGone', error.message) } if (!committed.item) { throw error 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 355a6c541f2..691cd9dcbcd 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 @@ -14,10 +14,12 @@ import type { AgentJournalMessageItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionCancelResult, - AgentSessionSendResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionCancelResult, + type AgentSessionRefusalCause, + type AgentSessionSendResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -30,7 +32,7 @@ import type { import { agentSessionFailureRejection, agentSessionFailureText, - providerStartupFailureFact + structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' @@ -60,8 +62,11 @@ export type TurnOutcome = | { ok: true; value: TValue } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function invalid( + cause: AgentSessionRefusalCause, + message: string +): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: refuse('agent_session_operation_invalid', cause, message) } } /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` @@ -84,10 +89,8 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - return { - state: 'rejected', - ...agentSessionFailureRejection(providerStartupFailureFact(error)) - } + const words = structuredAgentSessionStartFailure({ error }) + return { state: 'rejected', reason: words.text, rejection: words.failure } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -128,7 +131,10 @@ export async function performSend( .submissions() .find((entry) => entry.clientMessageId === input.clientMessageId) if (existing && existing.payloadFingerprint !== input.payloadFingerprint) { - return invalid(`Message id ${input.clientMessageId} was already used for another send.`) + return invalid( + 'messageIdReused', + `Message id ${input.clientMessageId} was already used for another send.` + ) } if (existing) { return { @@ -139,7 +145,7 @@ export async function performSend( try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch { - return invalid('The message could not be recorded and was not sent.') + return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } return { ok: true, 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 5ed301295bf..5663bf13789 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,5 @@ +import type { AgentSessionFailureTextContext } from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureTextContext } from './structured-agent-session-send-preparation' 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' @@ -116,6 +118,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, + failureTextContext: structuredAgentSessionFailureTextContext(record), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || @@ -204,6 +207,7 @@ async function retryUnexpectedExitSettlement(input: { stableSettlementId: string verdict: StructuredAgentSessionTurnVerdict exitedDuringStartup: boolean + failureTextContext: AgentSessionFailureTextContext showUnexpectedExitOutcome?: boolean }): Promise { return settleStructuredAgentSessionDeadGeneration({ @@ -215,6 +219,7 @@ async function retryUnexpectedExitSettlement(input: { pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, ...(input.event.failure ? { exitFailure: input.event.failure } : {}), + failureTextContext: input.failureTextContext, ...(input.exitedDuringStartup ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 5a3370c910f..fe3e443c495 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -29,12 +29,12 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsTaskStop: true }), RECORD ) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks for a stop on a host that predates the stop-capability field', () => { const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks the user to wait when the provider exposes no stop at all', () => { @@ -43,7 +43,7 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsStopAll: false }), RECORD ) - expect(blocked).toBe('Wait for background tasks to finish before using this command.') + expect(blocked?.message).toBe('Wait for background tasks to finish before using this command.') }) it('still refuses on the open turn, not on the work the strip now shows', () => { @@ -63,7 +63,7 @@ describe('conversationCommandBlocked background tasks', () => { } ] }) as unknown as ReturnType - expect(conversationCommandBlocked(ctx, RECORD)).toBe( + expect(conversationCommandBlocked(ctx, RECORD)?.message).toBe( 'Wait for the current turn to finish before using this command.' ) }) 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 fe6e5501975..3722731aab6 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 @@ -1,33 +1,48 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { + refuse, + type AgentSessionRefusalCause, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' + +function blocked(cause: AgentSessionRefusalCause, message: string): AgentSessionWireRefusal { + return refuse('agent_session_operation_invalid', cause, message) +} export function conversationCommandBlocked( ctx: AgentSessionTurnContext, record: AgentSessionRecord -): string | null { +): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { - return 'agent_session_rewind:outcome-unknown' + return blocked('rewindUnconfirmed', 'agent_session_rewind:outcome-unknown') } if ( record.conversationCommand?.command === 'clear' && record.conversationCommand.phase === 'committed' && record.conversationCommand.replacementSessionId ) { - return 'This conversation has been cleared. Open the current conversation to continue.' + return blocked( + 'conversationCleared', + 'This conversation has been cleared. Open the current conversation to continue.' + ) } if ( record.conversationCommand?.state === 'unknown' && record.conversationCommand.phase === 'prepared' ) { - return 'The previous conversation operation is unconfirmed.' + return blocked( + 'conversationCommandUnconfirmed', + 'The previous conversation operation is unconfirmed.' + ) } if (record.lease.handoffStage || record.lease.handoffOperationId) { - return 'Wait for the session handoff to finish.' + return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } if (activeStructuredAgentSessionTurnId(items)) { - return 'Wait for the current turn to finish before using this command.' + return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( items.some( @@ -36,16 +51,22 @@ export function conversationCommandBlocked( item.body.resolution.state === 'pending' ) ) { - return 'Resolve the pending question or approval before using this command.' + return blocked( + 'promptPending', + 'Resolve the pending question or approval before using this command.' + ) } const backgroundTasks = ctx.adapter.backgroundTaskState?.(ctx.sessionId) if (backgroundTasks?.state === 'monitoring') { // Only ask for a stop the host can actually perform. A provider that // exposes neither a targeted nor an untargeted stop would otherwise leave // the command refused behind an instruction nobody can follow. - return backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false - ? 'Stop background tasks before using this command.' - : 'Wait for background tasks to finish before using this command.' + return blocked( + 'backgroundTasksRunning', + backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + ? 'Stop background tasks before using this command.' + : 'Wait for background tasks to finish before using this command.' + ) } if ( ctx.journal.submissions().some( @@ -55,7 +76,10 @@ export function conversationCommandBlocked( (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) ) ) { - return 'Resolve pending or unconfirmed messages before using this command.' + return blocked( + 'messagesUnsettled', + 'Resolve pending or unconfirmed messages before using this command.' + ) } return null } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index e5a0283ba83..d33716ba357 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -1,3 +1,4 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { sendStructuredAgentSessionTurn } from './structured-agent-session-host-mutations' import { runStructuredConversationCommand, @@ -20,10 +21,11 @@ export class StructuredConversationCommandController { this.pending.has(params.envelope.sessionId) ? Promise.resolve({ ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Wait for the conversation operation to finish.' - } + refusal: refuse( + 'agent_session_operation_invalid', + 'conversationCommandInFlight', + 'Wait for the conversation operation to finish.' + ) }) : sendStructuredAgentSessionTurn(this.context(), caller, params) @@ -33,10 +35,11 @@ export class StructuredConversationCommandController { if (pending && pending.key !== key) { return Promise.resolve({ ok: false as const, - refusal: { - code: 'agent_session_operation_invalid' as const, - message: 'Wait for the conversation operation to finish.' - } + refusal: refuse( + 'agent_session_operation_invalid', + 'conversationCommandInFlight', + 'Wait for the conversation operation to finish.' + ) }) } const entry = pending ?? { key, count: 0 } 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 b3543b36ed5..8723a4372dc 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 @@ -119,10 +119,7 @@ export function runStructuredConversationCommand( ? null : conversationCommandBlocked(ctx, record) if (blocked) { - return { - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: blocked } - } + return { ok: false, refusal: blocked } } const replacementSessionId = command === 'clear' diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index 5ea205a527c..8ca3c39f0c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -17,6 +17,7 @@ export function rewindRefusal(reason: AgentSessionRewindReason): { knownReason === 'outcome-unknown' ? 'agent_session_operation_unknown' : 'agent_session_operation_invalid', + cause: knownReason === 'outcome-unknown' ? 'rewindUnconfirmed' : 'rewindRefused', message: `agent_session_rewind:${knownReason}`, rewindReason: knownReason } diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index d470735b032..5147eb8a94d 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -5,6 +5,7 @@ // journal, and a call site written there would compile however wrong it was. The runtime hands over // the facts it owns — the account homes it recognises, the records it holds — and this decides. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' @@ -106,10 +107,11 @@ export function findConflictingStructuredAdoption(input: { export function structuredAdoptionConflictError( ownership: StructuredAgentSessionAdoptionOwnership ): Error { - return new Error( + return agentSessionRefusalError( agentSessionLeaseAdmitsWriter(ownership.lease) ? 'agent_session_conflict' - : 'agent_session_ownership_unknown' + : 'agent_session_ownership_unknown', + 'conversationHeldElsewhere' ) } @@ -152,5 +154,5 @@ export async function resolveStructuredAgentSessionAdoption(input: { } // Refuse rather than fall back to the default home. Resuming under a home that does not hold the // conversation is how a "resume" silently becomes a blank chat wearing the old chat's name. - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'transcriptNotFound') } diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 2e7053e8a1b..3e88e92cfbc 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionStoreState } from './agent-session-record-store-file' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' @@ -9,7 +10,7 @@ export function commitConversationCommandRecord( ): void { const record = state.records.get(sessionId) if (!record || record.lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', 'leaseMoved') } state.records.set(sessionId, { ...record, conversationCommand: command }) if ( @@ -18,7 +19,7 @@ export function commitConversationCommandRecord( command.replacementSessionId ) { if (!state.records.has(command.replacementSessionId)) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'recordMissing') } state.visibleSessionIds.delete(sessionId) state.visibleSessionIds.add(command.replacementSessionId) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 580fbeda255..1e8c5dc97dd 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -6,6 +6,7 @@ * observed process identity, and a proved provider handle — in that order, at one fence. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { adjudicateAgentSessionRestart, agentSessionRestartEvictionSettlementId, @@ -40,10 +41,10 @@ export function withLease( export function assertFence(lease: AgentSessionLease, fence: number): void { if (lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', 'leaseMoved') } if (lease.unreconciled) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', 'hostReconciling') } } @@ -65,7 +66,7 @@ export function reserveAgentSessionOwner(args: { probe: args.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.cause) } if (decision.decision === 'retry-reservation') { return { record, disposition: 'retry-reservation' } @@ -108,11 +109,11 @@ export function commitAgentSessionProcessIdentity( const { record } = args assertFence(record.lease, args.fence) if (record.lease.claimStatus !== 'reserved' || record.lease.ownerProcess !== null) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'spawnIdentityMismatch') } if (record.lease.reservedSpawnToken !== args.process.spawnToken) { // Why: a child that cannot echo the reserved token is not the process Orca started. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'spawnIdentityMismatch') } return withLease(record, { ...record.lease, @@ -140,7 +141,7 @@ export function proveAgentSessionOwner(args: { record.lease.handoffStage !== 'new-owner-proving' || record.lease.ownerProcess === null ) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'spawnIdentityMismatch') } if (args.link.handle.provider !== record.provider) { throw new Error('agent_session_provider_handle_provider_mismatch') diff --git a/src/main/runtime/agent-session-operation-admission.test.ts b/src/main/runtime/agent-session-operation-admission.test.ts index 0b8b9ff91f2..3ed9a2f75df 100644 --- a/src/main/runtime/agent-session-operation-admission.test.ts +++ b/src/main/runtime/agent-session-operation-admission.test.ts @@ -48,6 +48,10 @@ describe('global agent-session operation admission', () => { fingerprint: 'different-send', now: NOW + 1 }).decision - ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict', + cause: 'operationIdReused' + }) }) }) diff --git a/src/main/runtime/agent-session-processless-reservation.ts b/src/main/runtime/agent-session-processless-reservation.ts index 9415ba8fcb7..2c3cb572db1 100644 --- a/src/main/runtime/agent-session-processless-reservation.ts +++ b/src/main/runtime/agent-session-processless-reservation.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../shared/agent-session-record' export type AgentSessionReservationProcesslessProof = { @@ -12,13 +13,13 @@ function assertReservation( args: AgentSessionReservationProcesslessProof ): void { if (record.lease.runtimeFence !== args.fence || record.lease.unreconciled) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', 'leaseMoved') } if ( record.lease.claimStatus !== 'reserved' || record.lease.reservedSpawnToken !== args.spawnToken ) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') } } @@ -34,7 +35,7 @@ export function setAgentSessionReservationProcesslessProof( return record } if (record.lease.ownerProcess !== null) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') } return { ...record, diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts index f0408901868..5e844a8cc1c 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionProviderHandleChainHead, appendAgentSessionProviderHandleLink, @@ -20,7 +21,7 @@ export function recordAgentSessionProviderHandle(args: { throw new Error('agent_session_provider_handle_invalid') } if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') } const providerHandleChain = appendAgentSessionProviderHandleLink( record.providerHandleChain, diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index 8cf88d685f3..6da38470982 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOptionsReplacement, AgentSessionRecord @@ -8,7 +9,7 @@ export function replaceAgentSessionRecordOptions( replacement: AgentSessionOptionsReplacement ): AgentSessionRecord { if (record.lease.runtimeFence !== replacement.fence || record.lease.claimStatus !== 'live') { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') } return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 58817a7e59c..c9551a72c0c 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { setVisibleSessionId } from './agent-session-visible-tab-index' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' @@ -346,11 +347,9 @@ export class AgentSessionRecordStore { return this.transact(() => { const record = this.state.records.get(sessionId) if (!record) { - throw new Error( - this.isSessionUnreadable(sessionId) - ? 'execution_owner_reconciling' - : 'agent_session_identity_required' - ) + throw this.isSessionUnreadable(sessionId) + ? agentSessionRefusalError('execution_owner_reconciling', 'recordUnreadable') + : agentSessionRefusalError('agent_session_identity_required', 'recordMissing') } const next = apply(record) this.state.records.set(sessionId, next) diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index de8a452c87a..6fd2c421000 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -4,6 +4,10 @@ // — can be read on its own. Every value parsed here re-enters from a file this process did not // necessarily write, including one written by an older or newer build. +import { + isAgentSessionRefusalCause, + type AgentSessionRefusalCause +} from '../../shared/agent-session-wire-refusals' import { z } from 'zod' import { AGENT_SESSION_RESUME_FAILURE_OUTCOMES, @@ -28,6 +32,7 @@ const failureSchema = z.object({ failedAt: z.number().int().nonnegative(), outcome: z.enum(AGENT_SESSION_RESUME_FAILURE_OUTCOMES), reason: z.string().max(MAX_FAILURE_FIELD_LENGTH), + cause: z.string().min(1).optional(), latestPrompt: z.string().max(MAX_FAILURE_FIELD_LENGTH), latestUserItemId: z.string().max(MAX_FAILURE_FIELD_LENGTH).nullable() }) @@ -47,7 +52,10 @@ export type AgentSessionResumeFailureRecord = { marker: AgentSessionResumeMarker failedAt: number outcome: AgentSessionResumeFailureOutcome + /** The refusal code, as it always was; the renderer's guidance keys on it. */ reason: string + /** The refusal's situation beside the code; absent on older records and non-refusals. */ + cause?: AgentSessionRefusalCause /** The prompt the offer quoted, snapshotted because the session may no longer be readable. */ latestPrompt: string /** The chat's newest user message when this was filed, as the marker records it at teardown. A @@ -109,7 +117,11 @@ function parseFailures(value: unknown): AgentSessionResumeFailureRecord[] { return (Array.isArray(value) ? value : []).flatMap((failure: unknown) => { const parsed = failureSchema.safeParse(failure) const marker = parsed.success ? parseAgentSessionResumeMarker(parsed.data.marker) : null - return parsed.success && marker ? [{ ...parsed.data, marker }] : [] + if (!parsed.success || !marker) { + return [] + } + const { cause, ...rest } = parsed.data + return [{ ...rest, marker, ...(isAgentSessionRefusalCause(cause) ? { cause } : {}) }] }) } diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 4bd3bf9b4db..10ccb0a2ba4 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -9,6 +9,7 @@ * inside a transaction, which is what makes the record and its operation row land together. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionOperationKey, evaluateAgentSessionOperation, @@ -110,7 +111,7 @@ export function requireAgentSessionRecordForReplay( if (!record) { // Why: the recorded effect is no longer reconstructable, and re-running it would be a second // spawn rather than a replay. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'recordMissing') } return record } @@ -126,11 +127,11 @@ export function admitPendingAgentSessionReservationReplay( probe: request.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.cause) } if (decision.decision !== 'retry-reservation') { // A replay may continue only its still-present reservation; recovery requires a fresh intent. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'replaySuperseded') } return record } @@ -168,10 +169,10 @@ export function applyAgentSessionReservation( const existing = state.records.get(request.sessionId) if (!existing) { if (state.unreadableRecords.has(request.sessionId)) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', 'recordUnreadable') } if (request.expectedFence !== null) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', 'recordMissing') } assertSurfaceTabIdUnheld(state, request) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } @@ -183,7 +184,7 @@ export function applyAgentSessionReservation( existing.accountHome.path !== request.accountHome.path ) { // Why: location, provider, and account are the session identity; changing one is a fork. - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', 'identityMismatch') } // A create may take over only a record that never bound a conversation and whose last // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. @@ -192,7 +193,7 @@ export function applyAgentSessionReservation( !request.adoptedHandleLink && agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' if (request.expectedFence === null && !recreatable) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', 'sessionExists') } const pinned = { ...existing, @@ -237,7 +238,7 @@ function assertAdoptedConversationUnowned( (link) => agentSessionProviderHandleRoot(link.handle) === root ) if (holdsSameConversation) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', 'conversationHeldElsewhere') } } } @@ -252,11 +253,11 @@ function assertSurfaceTabIdUnheld( return } if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', 'requestMalformed') } for (const record of state.records.values()) { if (record.sessionId !== request.sessionId && record.surfaceTabId === request.surfaceTabId) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', 'tabIdTaken') } } } @@ -311,7 +312,7 @@ export function commitAgentSessionReservation( ): AgentSessionReserveResult { const decision = evaluateAgentSessionReserveOperation(state, request) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.cause) } if (decision.decision === 'replay') { let record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index b7851709586..38accabe90c 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -8,6 +8,7 @@ // 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 { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' @@ -36,7 +37,7 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { const { record } = args assertFence(record.lease, args.expectedFence) if (!isSurfaceReleasableAgentSessionRecord(record)) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') } return withLease(record, { ...record.lease, diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 8fcef7e4a48..45ec8bc4a6b 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -7,11 +7,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in' +const STARTUP_TEXT = 'The provider stopped before it finished starting.' let claude = createScriptedClaudeRuntime([SESSION]) @@ -60,20 +64,21 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { }) expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(statusTexts(events)).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in/) - ) - ) + await vi.waitFor(() => expect(statusTexts(events)).toContainEqual(STARTUP_TEXT)) // Never written, so it did not happen: refused, not left in doubt. const submission = host .journalSnapshot(SESSION) .submissions.find( (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) ) - expect(submission).toMatchObject({ dispatchState: 'rejected' }) + // The stderr the exit carried is beside the sentence, as a log detail, and never in it. + expect(submission).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed', detail: { text: DIAGNOSTIC, audience: 'log' } } + }) }) }) 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 da1135f588f..3564c97284f 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 @@ -10,11 +10,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STARTUP_TEXT = 'The provider stopped before it finished starting.' let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -67,7 +71,7 @@ function submission(host: StructuredAgentSessionHost, clientMessageId: string) { /** The CLI keeps dying at startup: the latest child exits with the diagnostic once it exists. */ async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -82,7 +86,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ok: true }) await failLatestStart(host, 1) - expect(statusRows(host)).toEqual([expect.stringContaining('not signed in')]) + expect(statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) // The delivery loop asks for the child back and the message waits for its start; the CLI @@ -99,13 +103,11 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { expect(submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) @@ -154,12 +156,11 @@ describe('a send while the first Claude start is still answering initialize', () expect(submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.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 817b00144bc..f5193fe8eb8 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 @@ -11,11 +11,15 @@ import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' 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)' +const STARTUP_TEXT = 'The provider stopped before it finished starting.' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => void): Promise { @@ -91,7 +95,7 @@ function submission(host: StructuredAgentSessionHost, clientMessageId: string) { async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await eventually(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await eventually(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -143,13 +147,11 @@ describe('a send whose restarted Claude child dies before it proves its start', expect(submission(host, sent)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') @@ -180,15 +182,13 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => expect(submission(host, sent)).toMatchObject({ dispatchState: 'rejected', - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) await waitForStructuredAgentSessionRecovery() - expect(statusRows(host)).toEqual([ - `The provider stopped before it finished starting: ${DIAGNOSTIC}.`, - `The provider stopped before it finished starting: ${DIAGNOSTIC}.` - ]) + expect(statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) } ) @@ -214,9 +214,7 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => { const seen = received(events) expect(seen.submissions.get(sent)).toBe('rejected') - expect(seen.statusTexts).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ) + expect(seen.statusTexts).toContainEqual(STARTUP_TEXT) // The subscriber ended up on the fence the exit published, not the one the restart did. expect(seen.fences.at(-1)).toBe(fence(host)) }) @@ -227,8 +225,8 @@ describe('a send whose restarted Claude child dies before it proves its start', }) describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber open throughout', () => { - const STARTUP_FAILURE = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in (rig).' + // The stderr rides beside the sentence as a log detail; the sentence is the same every time. + const STARTUP_FAILURE = STARTUP_TEXT /** Status rows a subscriber has been shown, one per row whatever frame carried it. */ function shownRows(events: AgentSessionSubscribeEvent[]): Map { diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 50527f01ac5..e1286300d5a 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -336,11 +336,11 @@ describe('a structured Claude session over agentSession.*', () => { await waitForStructuredAgentSessionRecovery() const guidance = itemsOf(await subscribe()).find((item) => item.body?.kind === 'status') + // The adapter typed the refusal, so the row names the situation rather than quoting Orca. expect(guidance?.body).toMatchObject({ kind: 'status', - text: expect.stringMatching( - /stopped before it finished starting: .*not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s - ) + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + failure: { kind: 'notSignedIn' } }) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) // A failed start is not auto-resumed into the same failure. diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index f2143b9772c..b7d471e71b7 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' @@ -156,7 +157,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostUnsupported') } const settings = this.requireStore().getSettings() const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 02ae6a335f2..ea4575fa8c5 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -15,6 +15,10 @@ import { nestedWorkerDepthExceededMessage } from '../../../shared/nested-worker-depth' import { OrchestrationError } from '../orchestration/orchestration-error' +import { + AgentSessionRefusalError, + agentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -297,3 +301,45 @@ describe('structured worker dispatch preamble errors', () => { }) }) }) + +describe('thrown agent-session refusals', () => { + const meta = { runtimeId: 'runtime-1' } + + // Released clients classify a thrown refusal by its wire code and message; both must read + // exactly as the bare `Error(code)` this replaced. + it.each([ + ['agent_session_ownership_unknown', 'agent_session_ownership_unknown'], + ['structured_agent_session_unsupported', 'runtime_error'], + ['agent_session_journal_unreadable', 'runtime_error'] + ] as const)('keeps %s on the wire as it was, and adds its cause in data', (code, wire) => { + const before = mapRuntimeError('req_1', meta, new Error(code)) + const after = mapRuntimeError('req_1', meta, agentSessionRefusalError(code, 'hostDisabled')) + expect(after.error.code).toBe(before.error.code) + expect(after.error.code).toBe(wire) + expect(after.error.message).toBe(before.error.message) + expect(after.error.message).toBe(code) + expect(after.error.data).toEqual({ refusal: { code, cause: 'hostDisabled' } }) + }) + + it('carries no cause in data when the refusal named none', () => { + const response = mapRuntimeError( + 'req_1', + meta, + new AgentSessionRefusalError({ + code: 'agent_session_conflict', + message: 'Another process claims this session.' + }) + ) + expect(response.error).toEqual({ + code: 'agent_session_conflict', + message: 'agent_session_conflict', + data: { refusal: { code: 'agent_session_conflict' } } + }) + }) + + it('exposes no code property another passthrough could claim', () => { + expect('code' in agentSessionRefusalError('agent_session_conflict', 'claimConflicted')).toBe( + false + ) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index c2402d87849..98cd0e29c58 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -2,6 +2,11 @@ // runtime/browser error allowlists define the contract the CLI relies on to // format human-facing messages. Centralizing this mapping keeps the allowlist // auditable in one place instead of spread across per-method branches. +import { + agentSessionRefusalReference, + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' @@ -158,6 +163,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { const message = error instanceof Error ? error.message : String(error) + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalErrorResponse(id, meta, error) + } if ( error instanceof Error && 'code' in error && @@ -228,6 +236,27 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow return errorResponse(id, meta, 'runtime_error', message) } +/** + * A thrown agent-session refusal, mapped before any `'code' in error` passthrough so no other + * subsystem's code set can claim it. Wire code and message are exactly what the bare `Error(code)` + * it replaced produced — released clients classify both — and the refusal's cause rides only in + * `data`, which they ignore. + */ +function agentSessionRefusalErrorResponse( + id: string, + meta: RpcEnvelopeMeta, + error: AgentSessionRefusalError +): RpcFailure { + const { code } = error.refusal + return errorResponse( + id, + meta, + RUNTIME_PASSTHROUGH_CODES.has(code) ? code : 'runtime_error', + code, + { refusal: agentSessionRefusalReference(error.refusal) } + ) +} + export const computerErrorData = computerUseErrorRecoveryData // Why: browser errors carry a structured .code property (BrowserError from diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 3d6981ed4a1..9d0c40c3735 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -585,7 +585,8 @@ describe('the inner attach reserves under its own id', () => { }) expect(forwarded).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + cause: 'operationIdReused' }) const derived = deriveAgentLaunchChildOperationId(OPERATION_ID) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index ab52c2aa855..7c255c970bc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -12,6 +12,7 @@ * in. Both callers run the same two halves, so orchestration gets that guarantee too. */ +import { refuse } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { AgentSessionAttachResult, @@ -142,10 +143,11 @@ export async function commitStructuredAgentSessionCreate(args: { console.warn('[agent-session] create committed before tab publication failed', error) return { ok: false, - refusal: { - code: 'agent_session_operation_unknown', - message: 'The chat may have been created, but its tab could not be confirmed.' - } + refusal: refuse( + 'agent_session_operation_unknown', + 'tabUnconfirmed', + 'The chat may have been created, but its tab could not be confirmed.' + ) } } return result 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 95e3e3d6eee..8a4bb08fc53 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -8,6 +8,7 @@ // for old mobile clients while structured chat is enabled so they receive a fallback row, and that // path constructs the host. `agentSession.*` stays refused either way, which is what this gate is for. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' @@ -27,7 +28,10 @@ export function supportsStructuredSessions(ctx: RpcContext): boolean { export function requireStructuredCapability(ctx: RpcContext): void { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError( + 'structured_agent_session_unsupported', + 'clientCapabilityMissing' + ) } } @@ -35,7 +39,7 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo requireStructuredCapability(ctx) const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostDisabled') } return host } @@ -64,11 +68,14 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo */ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { if (!supportsStructuredAgentSessionCapability(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError( + 'structured_agent_session_unsupported', + 'clientCapabilityMissing' + ) } const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostDisabled') } return host } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts index 24d9fa2aec4..b538d40a4a2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts @@ -12,6 +12,7 @@ import { AGENT_SESSION_WIRE_REFUSAL_CODES, + isAgentSessionRefusalError, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' @@ -43,7 +44,11 @@ function wireRefusalCode(error: unknown): AgentSessionWireRefusalCode | null { function precommitRefusal(error: unknown): AgentSessionWireRefusal { const code = wireRefusalCode(error) if (code) { - return { code, message: 'Orca cannot open a structured agent chat for this workspace.' } + return { + code, + cause: (isAgentSessionRefusalError(error) && error.refusal.cause) || 'hostUnsupported', + message: 'Orca cannot open a structured agent chat for this workspace.' + } } const message = error instanceof Error ? error.message : String(error) // A code-less failure here is often a defect, not a policy answer; the refusal keeps the user @@ -51,6 +56,7 @@ function precommitRefusal(error: unknown): AgentSessionWireRefusal { console.warn('[agent-session] create refused before it committed anything', error) return { code: UNCODED_PRECOMMIT_REFUSAL_CODE, + cause: 'hostFault', message: `Orca could not prepare a structured agent chat for this workspace: ${message}` } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 47f30a5030d..705ffdfbf30 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -9,7 +9,10 @@ // reads its own record and answers with that record's workspace and provider, so a client knowing // only a session id cannot aim the publication somewhere else. -import { isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' +import { + isAgentSessionRefusalError, + isAgentSessionWireRefusalCode +} from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionReveal } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import { refuseAgentSessionMutation } from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { defineMethod } from '../core' @@ -39,6 +42,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ } return refuseAgentSessionMutation({ code, + ...(isAgentSessionRefusalError(error) && error.refusal.cause + ? { cause: error.refusal.cause } + : {}), message: code === 'structured_agent_session_unsupported' ? 'This host cannot open that chat.' diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 66d44d4b782..a96d9f59422 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -5,6 +5,7 @@ // not exist rather than receiving the journal or mutation surface. Session-tab // inventory may expose only a metadata placeholder for an incapable mobile client. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' import type { z } from 'zod' import { @@ -74,7 +75,7 @@ async function resolveClientSuppliedAttach(params: z.infer, await ensureHostInstalled(ctx) const host = requireHost(ctx) if (!host.supportsCreate(params.location, params.agent)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostUnsupported') } const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { @@ -129,7 +130,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CreateSupportParams, handler: async (params, ctx) => { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError( + 'structured_agent_session_unsupported', + 'clientCapabilityMissing' + ) } return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) } @@ -140,7 +144,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) if (params.envelope.expectedRuntimeFence !== null) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', 'requestMalformed') } // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so // a client can tell "nothing was created" from "the outcome is unknown". diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index af1bc9a4af7..e68fdc84759 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -1,6 +1,7 @@ // A structured-session runtime whose Claude children are scripted: the production runtime, // adapter, record store and host, with only the CLI process replaced. +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,6 +21,10 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +/** The error a real CLI's exit reaches the adapter as: Orca's message, the stderr as a log detail. */ +export function scriptedClaudeExitError(diagnostic: string): Error { + return withProviderDiagnostic(new Error(diagnostic), providerDiagnostic(diagnostic, 'log')) +} export type ScriptedClaudeBehavior = { /** Initialize never answers; only the child's exit settles it. */ initHangs?: boolean @@ -175,7 +180,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { } const exitAtSpawn = (child: ScriptedClaudeChild, diagnostic: string): void => { child.connection.closed = true - child.exit(new Error(diagnostic)) + child.exit(scriptedClaudeExitError(diagnostic)) } /** A pid whose process is gone has no start time to read. */ const readProcessStartTime = async (pid: number): Promise => { diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 97d3af94ae9..8e177a0df07 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -49,8 +49,9 @@ export type ProviderDiagnostic = { audience: ProviderDiagnosticAudience } -/** Stderr can be a whole dump; the row keeps enough to act on. */ -export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 2000 +/** Stderr can be a whole dump; the row keeps enough to act on. The same cap as the exit reason a + * lease record keeps, so a diagnostic never outgrows what the record may store. */ +export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 512 export type AgentSessionFailureFact = { kind: AgentSessionFailureKind @@ -73,9 +74,13 @@ export function agentSessionFailureFact( kind: AgentSessionFailureKind, extra: { detail?: ProviderDiagnostic; refusal?: AgentSessionRefusalReference } = {} ): AgentSessionFailureFact { + // Re-bounded here, so no writer can store more than the cap however it built the detail. + const detail = extra.detail + ? providerDiagnostic(extra.detail.text, extra.detail.audience) + : undefined return { kind, - ...(extra.detail ? { detail: extra.detail } : {}), + ...(detail ? { detail } : {}), ...(extra.refusal ? { refusal: extra.refusal } : {}) } } diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index ff1020ece74..1bdf5af9490 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -91,7 +91,11 @@ describe('acquisition compare-and-swap', () => { handoffOperationId: null, probe: MATCHED }) - ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_checkpoint_stale', + cause: 'fenceStale' + }) expect(acquire(held, MATCHED)).toEqual({ decision: 'granted', nextFence: 8 }) }) @@ -108,7 +112,11 @@ describe('acquisition compare-and-swap', () => { handoffOperationId: null, probe: MATCHED }) - ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_checkpoint_stale', + cause: 'fenceStale' + }) }) it('never grants a second owner on expiry alone', () => { @@ -116,11 +124,13 @@ describe('acquisition compare-and-swap', () => { const expired = lease({ leaseDeadlineAt: 1, lastRenewedAt: 1 }) expect(acquire(expired, INDETERMINATE)).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + cause: 'ownerUnproven' }) expect(acquire(expired, MATCHED)).toEqual({ decision: 'refused', - code: 'agent_session_conflict' + code: 'agent_session_conflict', + cause: 'ownerAlive' }) expect(acquire(expired, { outcome: 'pid-absent' })).toEqual({ decision: 'granted', @@ -131,14 +141,16 @@ describe('acquisition compare-and-swap', () => { it('refuses while unreconciled even with proof the owner is dead', () => { expect(acquire(lease({ unreconciled: true }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', - code: 'execution_owner_reconciling' + code: 'execution_owner_reconciling', + cause: 'hostReconciling' }) }) it('keeps a conflicted claim conflicted regardless of proof', () => { expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({ decision: 'refused', - code: 'agent_session_conflict' + code: 'agent_session_conflict', + cause: 'claimConflicted' }) }) @@ -148,7 +160,8 @@ describe('acquisition compare-and-swap', () => { ] as const)('refuses acquisition in stage %s', (handoffStage, code) => { expect(acquire(lease({ handoffStage }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', - code + code, + cause: 'ownerUnproven' }) }) @@ -161,7 +174,8 @@ describe('acquisition compare-and-swap', () => { }) expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + cause: 'handoffInFlight' }) expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ decision: 'retry-reservation', @@ -173,7 +187,8 @@ describe('acquisition compare-and-swap', () => { const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage: null }) expect(acquire(reserved, INDETERMINATE)).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + cause: 'ownerUnproven' }) expect(acquire(reserved, { outcome: 'reservation-unused' })).toEqual({ decision: 'granted', diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index 6c1d07820ad..108293f9e4b 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -15,7 +15,10 @@ import type { AgentSessionHandoffStage, AgentSessionLease } from './agent-session-record' -import type { AgentSessionOwnerVerdict } from './agent-session-wire-refusals' +import type { + AgentSessionOwnerVerdict, + AgentSessionRefusalCause +} from './agent-session-wire-refusals' export type AgentSessionIdentityMatchField = 'process-start-time' | 'spawn-token' @@ -44,7 +47,7 @@ export type AgentSessionAcquisitionDecision = | { decision: 'granted'; nextFence: number } /** The same acquisition operation re-entering its own reservation; no new fence, no new spawn. */ | { decision: 'retry-reservation'; fence: number } - | { decision: 'refused'; code: AgentSessionLeaseRefusalCode } + | { decision: 'refused'; code: AgentSessionLeaseRefusalCode; cause: AgentSessionRefusalCause } export type AgentSessionRestartAdjudication = /** A journal settlement latch survives restart without changing its handoff stage. */ @@ -133,22 +136,26 @@ export function evaluateAgentSessionAcquisition(args: { }): AgentSessionAcquisitionDecision { const { lease, expectedFence, handoffOperationId, probe } = args if (lease.unreconciled) { - return { decision: 'refused', code: 'execution_owner_reconciling' } + return { decision: 'refused', code: 'execution_owner_reconciling', cause: 'hostReconciling' } } if (!isAgentSessionFenceCurrent(lease, expectedFence)) { - return { decision: 'refused', code: 'agent_session_checkpoint_stale' } + return { decision: 'refused', code: 'agent_session_checkpoint_stale', cause: 'fenceStale' } } if (lease.claimStatus === 'conflicted') { - return { decision: 'refused', code: 'agent_session_conflict' } + return { decision: 'refused', code: 'agent_session_conflict', cause: 'claimConflicted' } } if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') { // Why: no stage expires into an owner; recovery is resolved by proof or by the user. - return { decision: 'refused', code: 'agent_session_ownership_unknown' } + return { decision: 'refused', code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } } 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. - return { decision: 'refused', code: 'agent_session_operation_conflict' } + return { + decision: 'refused', + code: 'agent_session_operation_conflict', + cause: 'handoffInFlight' + } } if ( lease.ownerProcess === null && @@ -163,19 +170,16 @@ export function evaluateAgentSessionAcquisition(args: { if (!isProvenDeadProbe(probe)) { // Why: a lapsed deadline means Orca stopped hearing from the owner, not that the child // stopped editing files and spending tokens. - return { - decision: 'refused', - code: isProvenAliveProbe(probe) - ? 'agent_session_conflict' - : 'agent_session_ownership_unknown' - } + return isProvenAliveProbe(probe) + ? { decision: 'refused', code: 'agent_session_conflict', cause: 'ownerAlive' } + : { decision: 'refused', code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } if (lease.claimStatus === 'reserved' && probe.outcome !== 'reservation-unused') { // Why: a reservation with no proven process is not a free lease — the crash may have lost // the race with the spawn rather than beaten it. - return { decision: 'refused', code: 'agent_session_ownership_unknown' } + return { decision: 'refused', code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index 306450f8a63..9629f9c215a 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -163,11 +163,15 @@ describe('admitAgentSessionMutation', () => { it('surfaces a ledger refusal verbatim', () => { const admission = admitAgentSessionMutation({ ...base, - ledger: { decision: 'refused', code: 'agent_session_operation_expired' } + ledger: { + decision: 'refused', + code: 'agent_session_operation_expired', + cause: 'operationExpired' + } }) expect(admission).toMatchObject({ decision: 'refused', - refusal: { code: 'agent_session_operation_expired' } + refusal: { code: 'agent_session_operation_expired', cause: 'operationExpired' } }) }) }) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index f0cae00c1b3..1ebef80cc3f 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -14,6 +14,7 @@ import type { import { agentSessionLeaseAdmitsWriter } from './agent-session-lease-adjudication' import type { AgentSessionLease } from './agent-session-record' import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' +import { refuse } from './agent-session-wire-refusals' /** * Stable digest over the fields that define what this call DOES. Keys are @@ -63,11 +64,11 @@ export function agentSessionFingerprintConflict( ): AgentSessionWireRefusal | null { return envelope.payloadFingerprint === hostFingerprint ? null - : { - code: 'agent_session_operation_conflict', - message: - 'The payload does not match the fingerprint the client declared for this operation.' - } + : refuse( + 'agent_session_operation_conflict', + 'fingerprintMismatch', + 'The payload does not match the fingerprint the client declared for this operation.' + ) } export type AgentSessionMutationAdmission = @@ -103,10 +104,11 @@ export function admitAgentSessionMutation(input: { if (ledger.decision === 'refused') { return { decision: 'refused', - refusal: { - code: ledger.code, - message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` - } + refusal: refuse( + ledger.code, + ledger.cause, + `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + ) } } if (ledger.decision === 'replay') { @@ -129,22 +131,24 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR return null } if (lease.unreconciled) { - return { - code: 'execution_owner_reconciling', - message: 'This host has not yet adjudicated the session lease.' - } + return refuse( + 'execution_owner_reconciling', + 'hostReconciling', + 'This host has not yet adjudicated the session lease.' + ) } if (lease.handoffStage !== null) { - return { - code: 'agent_session_conflict', - 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 { - code: 'agent_session_ownership_unknown', - message: 'The session has no live owner to accept writes.' + return lease.handoffStage === 'new-owner-proving' + ? refuse('agent_session_conflict', 'chatStarting', 'The chat is still starting.') + : refuse( + 'agent_session_conflict', + 'ownerUnproven', + "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." + ) } + return refuse( + 'agent_session_ownership_unknown', + 'noLiveOwner', + 'The session has no live owner to accept writes.' + ) } diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts index 0710eade461..a85b63f619f 100644 --- a/src/shared/agent-session-operation-ledger.test.ts +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -64,7 +64,8 @@ describe('operation admission', () => { admit(rows) expect(evaluate(rows, { fingerprint: 'fp-2' })).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + cause: 'operationIdReused' }) }) @@ -78,14 +79,19 @@ describe('operation admission', () => { const rows = new Map() expect(evaluate(rows, { operationId: 'not-an-operation-id' })).toEqual({ decision: 'refused', - code: 'agent_session_operation_invalid' + code: 'agent_session_operation_invalid', + cause: 'operationIdInvalid' }) // Why: a future-dated id would look new again after its own tombstone is collected. expect( evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1) }) - ).toEqual({ decision: 'refused', code: 'agent_session_operation_invalid' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_operation_invalid', + cause: 'operationIdInvalid' + }) expect( evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) }) .decision @@ -97,7 +103,8 @@ describe('operation admission', () => { const stale = operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1) expect(evaluate(rows, { operationId: stale })).toEqual({ decision: 'refused', - code: 'agent_session_operation_expired' + code: 'agent_session_operation_expired', + cause: 'operationExpired' }) expect( evaluate(rows, { operationId: operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) }) @@ -110,12 +117,14 @@ describe('operation admission', () => { admit(rows, { operationId: operationId(NOW, 'b'.repeat(32)) }) expect(evaluate(rows, { perClientLimit: 1 })).toEqual({ decision: 'refused', - code: 'agent_session_operation_capacity' + code: 'agent_session_operation_capacity', + cause: 'operationCapacity' }) // A different caller is still refused once the global cap is reached. expect(evaluate(rows, { callerKey: 'client-2', globalLimit: 1 })).toEqual({ decision: 'refused', - code: 'agent_session_operation_capacity' + code: 'agent_session_operation_capacity', + cause: 'operationCapacity' }) expect(evaluate(rows, { callerKey: 'client-2', perClientLimit: 1 }).decision).toBe('admit') }) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 765f8e51142..41c344878ac 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -1,3 +1,4 @@ +import type { AgentSessionRefusalCause } from './agent-session-wire-refusals' import { isAgentSessionRewindResult, type AgentSessionRewindReason, @@ -55,7 +56,14 @@ export type AgentSessionOperationOutcome = */ launch?: unknown } - | { status: 'failed'; code: string; message?: string; rewindReason?: AgentSessionRewindReason } + | { + status: 'failed' + code: string + message?: string + rewindReason?: AgentSessionRewindReason + /** Beside the code, so a replay names the situation the first answer did. */ + cause?: AgentSessionRefusalCause + } /** The effect may or may not have happened; replay this answer instead of spawning again. */ | { status: 'unknown' } @@ -78,7 +86,14 @@ export type AgentSessionOperationRefusalCode = export type AgentSessionOperationDecision = | { decision: 'replay'; row: AgentSessionOperationRow } | { decision: 'admit'; row: AgentSessionOperationRow } - | { decision: 'refused'; code: AgentSessionOperationRefusalCode } + | { + decision: 'refused' + code: AgentSessionOperationRefusalCode + cause: Extract< + AgentSessionRefusalCause, + 'operationIdInvalid' | 'operationIdReused' | 'operationExpired' | 'operationCapacity' + > + } /** NUL cannot occur in a caller key or operation id, so no pair can forge another pair's key. */ const OPERATION_KEY_SEPARATOR = '\u0000' @@ -228,19 +243,31 @@ export function evaluateAgentSessionOperation(args: { operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS ) { // Why: a future-dated id could look new again after its tombstone is collected. - return { decision: 'refused', code: 'agent_session_operation_invalid' } + return { + decision: 'refused', + code: 'agent_session_operation_invalid', + cause: 'operationIdInvalid' + } } const key = agentSessionOperationKey(callerKey, operationId) const existing = rows.get(key) if (existing) { return existing.fingerprint === fingerprint ? { decision: 'replay', row: existing } - : { decision: 'refused', code: 'agent_session_operation_conflict' } + : { + decision: 'refused', + code: 'agent_session_operation_conflict', + cause: 'operationIdReused' + } } if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { // Why: once a tombstone could have expired, an unseen replay must never be reinterpreted as // permission to start another fresh agent. - return { decision: 'refused', code: 'agent_session_operation_expired' } + return { + decision: 'refused', + code: 'agent_session_operation_expired', + cause: 'operationExpired' + } } const perClientLimit = args.perClientLimit ?? AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT const globalLimit = args.globalLimit ?? AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT @@ -253,7 +280,11 @@ export function evaluateAgentSessionOperation(args: { if (callerCount >= perClientLimit || rows.size >= globalLimit) { // Why: tombstones cannot be evicted early without making an old replay capable of spawning // again; reject new ids until retained rows age out. - return { decision: 'refused', code: 'agent_session_operation_capacity' } + return { + decision: 'refused', + code: 'agent_session_operation_capacity', + cause: 'operationCapacity' + } } return { decision: 'admit', From 5263550b2b1e2053ab3b727db3a62ebfa766c34d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:14:05 -0700 Subject: [PATCH 057/122] refactor(native-chat): one reader of why a submission was rejected classifyDispatchRejection(submission) returns { category, verdict, kind? }. It reads the typed rejection fact when the row carries one this build can place, and the legacy markers otherwise - all six, including not_delivered, which released clients printed as it was. The verdict is null only for a withdrawal, a host restart and a closed chat; write failures, a full queue and not-delivered stay failures. It replaces dispatchRejectionReasonIsInternal and every string comparison against the markers: the outbox reconcile, the rejection notice, and the send disposition, where a replayed Stop-withdrawn send no longer surfaces as a failed send. The journal reducer's echo-aliasing guard reads the narrow isWriteFailureSubmission, which matches the typed kind or the legacy prefix in any dispatch state, so its behaviour on legacy unknown rows is unchanged. --- .../journal-dispatch-reason-bound.test.ts | 9 +- .../agent-session-journal/journal-reducer.ts | 4 +- .../journal-row-builders.ts | 2 +- ...d-agent-session-dispatch-rejection.test.ts | 81 ++++++++++ ...ctured-agent-session-dispatch-rejection.ts | 149 ++++++++++++++---- src/shared/structured-agent-session-outbox.ts | 4 +- ...red-agent-session-send-disposition.test.ts | 64 +++++++- ...ructured-agent-session-send-disposition.ts | 29 ++-- 8 files changed, 284 insertions(+), 58 deletions(-) create mode 100644 src/shared/structured-agent-session-dispatch-rejection.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index b528d4c1b26..d7708c45f42 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -4,9 +4,9 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { + classifyDispatchRejection, DISPATCH_REJECTED_WRITE_FAILED, - dispatchRejectionReasonIsInternal, - dispatchRejectionWasTransportWriteFailure + isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' @@ -87,7 +87,8 @@ describe('dispatch reason bounding', () => { it('keeps a clipped transport failure classifiable', async () => { const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) - expect(dispatchRejectionWasTransportWriteFailure(stored)).toBe(true) - expect(dispatchRejectionReasonIsInternal(stored)).toBe(true) + // Read as an older host wrote it: by the reason alone. + expect(isWriteFailureSubmission({ reason: stored })).toBe(true) + expect(classifyDispatchRejection({ reason: stored })).toMatchObject({ kind: 'writeFailed' }) }) }) 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 2ff71d936c3..9c97c4fd5e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -27,7 +27,7 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' import { applyJournalDispatchRow } from './journal-dispatch-reducer' -import { dispatchRejectionWasTransportWriteFailure } from '../../../shared/structured-agent-session-dispatch-rejection' +import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -173,7 +173,7 @@ export function resolveJournalItemId( .find( (candidate) => candidate.dispatchState !== 'rejected' && - !dispatchRejectionWasTransportWriteFailure(candidate.reason) && + !isWriteFailureSubmission(candidate) && candidate.payloadFingerprint === fingerprint && state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 ) 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 21a8067ccce..b8b1e06961f 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 @@ -96,7 +96,7 @@ export function journalDispatchRowBuilder( /** `reason` is the only unbounded field written by Orca's own code: a provider error is * arbitrary text, and a multi-megabyte one reached the row verbatim. Bounded head-first, - * because `dispatchRejectionWasTransportWriteFailure` prefix-matches the value. Rows + * because `isWriteFailureSubmission` prefix-matches the value. Rows * written before this keep their full text, so readers still meet unbounded ones. */ function boundedDispatchReason(input: ResolveDispatchInput): string | null { if (input.state === 'accepted' || input.state === 'pending' || !input.reason) { diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts new file mode 100644 index 00000000000..a5e9a0edf2b --- /dev/null +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_FAILURE_KINDS } from './agent-session-failure' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + dispatchWriteFailureReason, + isWriteFailureSubmission +} from './structured-agent-session-dispatch-rejection' + +describe('classifyDispatchRejection', () => { + it.each([ + [DISPATCH_REJECTED_CANCELLED, 'withdrawn', null, 'cancelled'], + [DISPATCH_REJECTED_HOST_RESTARTED, 'undelivered', null, 'hostRestarted'], + [DISPATCH_REJECTED_PROVIDER_CLOSED, 'undelivered', null, 'chatClosed'], + ['not_delivered', 'undelivered', 'failure', 'notDelivered'], + [DISPATCH_REJECTED_QUEUE_FULL, 'transport', 'failure', 'queueFull'], + [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'transport', 'failure', 'queueFull'], + [dispatchWriteFailureReason(new Error('broken pipe')), 'transport', 'failure', 'writeFailed'], + ['provider_write_failed', 'transport', 'failure', 'writeFailed'] + ] as const)('reads the legacy marker %j', (reason, category, verdict, kind) => { + expect(classifyDispatchRejection({ reason })).toEqual({ category, verdict, kind }) + }) + + it('reads any other legacy reason as a sentence that failed the send', () => { + expect(classifyDispatchRejection({ reason: 'Claude does not support .bmp' })).toEqual({ + category: 'content', + verdict: 'failure' + }) + expect(classifyDispatchRejection({ reason: null })).toEqual({ + category: 'content', + verdict: 'failure' + }) + }) + + it('fails the send for every kind but a withdrawal, a host restart, or a closed chat', () => { + for (const kind of AGENT_SESSION_FAILURE_KINDS) { + const verdict = classifyDispatchRejection({ reason: 'x', rejection: { kind } }).verdict + expect([kind, verdict]).toEqual([ + kind, + kind === 'cancelled' || kind === 'hostRestarted' || kind === 'chatClosed' ? null : 'failure' + ]) + } + }) + + it('reads the typed fact over the sentence beside it', () => { + expect( + classifyDispatchRejection({ + reason: 'The provider stopped before it finished starting.', + rejection: { kind: 'providerStartFailed' } + }) + ).toEqual({ category: 'startFailed', verdict: 'failure', kind: 'providerStartFailed' }) + expect( + classifyDispatchRejection({ reason: 'Not sent.', rejection: { kind: 'queueFull' } }) + ).toMatchObject({ category: 'transport', verdict: 'failure' }) + }) + + it('falls back to the reason when a newer host wrote a kind this build does not know', () => { + const rejection = JSON.parse('{"kind":"futureKind"}') + expect( + classifyDispatchRejection({ reason: DISPATCH_REJECTED_CANCELLED, rejection }) + ).toMatchObject({ category: 'withdrawn', verdict: null }) + }) +}) + +describe('isWriteFailureSubmission', () => { + it('holds for a legacy row in any state that carries the marker', () => { + const reason = dispatchWriteFailureReason(new Error('closed before enqueue')) + expect(isWriteFailureSubmission({ reason })).toBe(true) + }) + + it('holds for a typed write failure and for nothing else', () => { + expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'writeFailed' } })).toBe(true) + expect(isWriteFailureSubmission({ reason: DISPATCH_REJECTED_QUEUE_FULL })).toBe(false) + expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'queueFull' } })).toBe(false) + expect(isWriteFailureSubmission({ reason: null })).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 50ee98c8546..98143b7b856 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -1,24 +1,20 @@ // Why a submission is `rejected`: the message provably did not happen. // -// Two different facts reach this state, and the reason string is what tells them -// apart. A CONTENT rejection is permanent — the provider looked at this payload -// and refused it, and it would refuse the same payload again; its reason is the -// provider's own explanation ("Claude messages support at most 20 images") and is -// meant to be read. A TRANSPORT rejection is transient — the frame was never -// handed to the provider at all, so the same text under a fresh id is a first -// delivery and the next attempt may well succeed; its reason names an internal -// cause and must NOT be put in front of a person. +// The host writes each rejection twice: `reason`, a sentence (or, for the cases older clients +// already recognise, one of the legacy markers below) that released clients print as it is, and +// `rejection`, the typed fact newer clients read. `classifyDispatchRejection` is the only place a +// reader judges either; the markers are exported for the writers that must keep writing them. // -// Both are `rejected` because both make the single claim that state exists to -// make: this message did not reach the provider. Neither is ever re-delivered -// under its own id — `rejected` is terminal in the reducer — so a retry rotates -// the client message id, which is a new message and cannot duplicate. -// -// Shared rather than main-only because both sides need it: the host writes the -// reason, and a client has to know which kind it is holding before it can decide -// whether the string is showable. +// Every rejection makes the one claim that state exists to make: this message did not reach the +// provider. None is ever re-delivered under its own id — `rejected` is terminal in the reducer — so +// a retry rotates the client message id, which is a new message and cannot duplicate. -import type { AgentSessionFailureFact } from './agent-session-failure' +import { + readAgentSessionFailureFact, + type AgentSessionFailureFact, + type AgentSessionFailureKind +} from './agent-session-failure' +import type { AgentJournalSubmission } from './agent-session-journal-types' export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' @@ -82,28 +78,119 @@ export function dispatchQueueFullRejection( return { reason: marker, rejection: { kind: 'queueFull' } } } -/** True for the internal transport marker, false for a provider's own words. */ -export function dispatchRejectionWasTransportWriteFailure( - reason: string | null | undefined -): boolean { +/** True for the internal transport marker, false for a provider's own words. Legacy-reason half + * of `isWriteFailureSubmission`; readers go through that or the classifier. */ +function dispatchRejectionWasTransportWriteFailure(reason: string | null | undefined): boolean { return ( reason === DISPATCH_REJECTED_WRITE_FAILED || reason?.startsWith(`${DISPATCH_REJECTED_WRITE_FAILED}: `) === true ) } +/** Written by restart reconciliation before rows carried a fact; released clients printed it. */ +const LEGACY_NOT_DELIVERED = 'not_delivered' + +/** + * What a rejection means for the chat, whatever wrote it: + * - `withdrawn`: the user's Stop took it back; nothing failed. + * - `undelivered`: accepted, then never handed over (Orca restarted, the chat closed, the provider + * never took it or stopped first). + * - `startFailed`: the agent it waited on did not start. + * - `content`: the provider, or Orca's check of the message, refused this payload. + * - `transport`: Orca could not hand it over. + */ +export type DispatchRejectionCategory = + | 'withdrawn' + | 'undelivered' + | 'startFailed' + | 'content' + | 'transport' + +export type DispatchRejectionClassification = { + category: DispatchRejectionCategory + /** `failure` when the chat reads Failed; null only when no one failed the user: a withdrawal, or + * a host restart or chat close that left the message undelivered. */ + verdict: 'failure' | null + /** The situation, when the row carried one or a legacy marker names it; absent for a legacy + * sentence, whose words are all a reader has. */ + kind?: AgentSessionFailureKind +} + +/** Status-row kinds never reach a submission; they are classified only so the table is total. */ +const KIND_CATEGORY = { + cancelled: 'withdrawn', + hostRestarted: 'undelivered', + chatClosed: 'undelivered', + notDelivered: 'undelivered', + providerExited: 'undelivered', + providerStartFailed: 'startFailed', + notSignedIn: 'startFailed', + historyTooLarge: 'startFailed', + restartFailed: 'startFailed', + providerRejected: 'content', + attachmentInvalid: 'content', + attachmentUnreadable: 'content', + queueFull: 'transport', + writeFailed: 'transport', + hostFault: 'transport', + compactionFailed: 'transport', + compactionUnconfirmed: 'transport', + cancelUnconfirmed: 'transport', + answerUnconfirmed: 'transport' +} satisfies Record + +const NO_FAILURE_KINDS: ReadonlySet = new Set([ + 'cancelled', + 'hostRestarted', + 'chatClosed' +]) + +/** The legacy markers, by the kind each stands for. */ +const LEGACY_MARKER_KINDS: ReadonlyMap = new Map([ + [DISPATCH_REJECTED_CANCELLED, 'cancelled'], + [DISPATCH_REJECTED_HOST_RESTARTED, 'hostRestarted'], + [DISPATCH_REJECTED_PROVIDER_CLOSED, 'chatClosed'], + [DISPATCH_REJECTED_QUEUE_FULL, 'queueFull'], + [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'queueFull'], + [LEGACY_NOT_DELIVERED, 'notDelivered'] +]) + +/** The kind a legacy marker stands for; undefined for any other reason, which is a sentence. */ +function legacyMarkerKind(reason: string | null): AgentSessionFailureKind | undefined { + if (dispatchRejectionWasTransportWriteFailure(reason)) { + return 'writeFailed' + } + return reason === null ? undefined : LEGACY_MARKER_KINDS.get(reason) +} + /** - * True when the reason is ours rather than the provider's, so it must not be - * shown verbatim. A content rejection carries the provider's own explanation and - * is the only kind a person should read. + * The one reader of why a submission was rejected. The typed fact decides when the row carries + * one this build can place; a row from an older host is read by its legacy marker, and any other + * reason is a sentence — a provider's, or Orca's before rows were typed — which reads as a + * content failure. */ -export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { +export function classifyDispatchRejection( + submission: Pick +): DispatchRejectionClassification { + const kind = + readAgentSessionFailureFact(submission.rejection)?.kind ?? legacyMarkerKind(submission.reason) + if (!kind) { + return { category: 'content', verdict: 'failure' } + } + return { + category: KIND_CATEGORY[kind], + verdict: NO_FAILURE_KINDS.has(kind) ? null : 'failure', + kind + } +} + +/** A submission that says Orca never handed it over, in any dispatch state: journals written + * before this state moved hold it as `unknown` carrying the marker. */ +export function isWriteFailureSubmission( + submission: Pick +): boolean { return ( - dispatchRejectionWasTransportWriteFailure(reason) || - reason === DISPATCH_REJECTED_QUEUE_FULL || - reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL || - reason === DISPATCH_REJECTED_CANCELLED || - reason === DISPATCH_REJECTED_HOST_RESTARTED || - reason === DISPATCH_REJECTED_PROVIDER_CLOSED + readAgentSessionFailureFact(submission.rejection)?.kind === 'writeFailed' || + dispatchRejectionWasTransportWriteFailure(submission.reason) ) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 1e6458a31b1..5aab2914e1e 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -5,7 +5,7 @@ import type { AgentSessionWireRefusalCode } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' /** `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. */ @@ -117,7 +117,7 @@ export function reconcileStructuredAgentSessionOutbox( } if ( submission?.dispatchState === 'rejected' && - submission.reason === DISPATCH_REJECTED_CANCELLED + classifyDispatchRejection(submission).category === 'withdrawn' ) { return [] } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 66a7a9b32fa..5858d41a751 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -4,6 +4,7 @@ // which is the whole reason it was split out. import { describe, expect, it } from 'vitest' +import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { @@ -26,7 +27,10 @@ const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOut queuedAt: 1 }) -function rejectedWith(reason: string | null): AgentSessionMutationResult { +function rejectedWith( + reason: string | null, + extra: { rejection?: AgentSessionFailureFact; replayed?: boolean } = {} +): AgentSessionMutationResult { const submission: AgentJournalSubmission = { clientMessageId: 'client-1', fence: 1, @@ -35,15 +39,16 @@ function rejectedWith(reason: string | null): AgentSessionMutationResult + } } function notice(reason: string | null): string | null { @@ -84,13 +89,56 @@ describe('what a rejection shows the user', () => { ) }) - it('names the cause of a start that died before it could take the message', () => { - // The host words this reason for the user: the child's own diagnostic, nothing internal. - const reason = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in.' + it('shows the sentence a host wrote for the person reading it', () => { + const reason = 'The provider stopped before it finished starting.' expect(notice(reason)).toBe(reason) }) + it('never puts the legacy not_delivered marker on screen', () => { + // Released clients printed it as it was; it is a marker, not a sentence. + expect(notice('not_delivered')).toBe( + 'Orca could not send your message — Retry to send it again.' + ) + }) + + it.each([ + ['the legacy marker alone', undefined], + ['the marker and its typed fact', { kind: 'cancelled' as const }] + ])('fails nothing for a Stop-withdrawn send, first reply or replay: %s', (_label, rejection) => { + for (const replayed of [false, true]) { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith(DISPATCH_REJECTED_CANCELLED, { rejection, replayed }), + createOperationId: () => 'unused' + }) + expect(disposition).toEqual({ + entries: [], + error: null, + blockedClientMessageId: null, + retryWithFreshClientMessageId: null + }) + } + }) + + it('reads a withdrawal off the typed fact whatever the reason says', () => { + const result = rejectedWith('Withdrawn.', { rejection: { kind: 'cancelled' } }) + if (!result.ok) { + throw new Error('expected rejected submission fixture') + } + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect( + disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result, + createOperationId: () => 'unused' + }).error + ).toBeNull() + }) + it('claims no cause when the rejection names none', () => { expect(notice(null)).toBe('Message was not sent.') }) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 72368dae921..1370bb59ba3 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -9,10 +9,7 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' -import { - dispatchRejectionReasonIsInternal, - dispatchRejectionWasTransportWriteFailure -} from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -128,14 +125,13 @@ export function structuredAgentSessionRejectionNotice(reason: string | null): st if (reason === null) { return 'Message was not sent.' } - if (dispatchRejectionWasTransportWriteFailure(reason)) { + const rejection = classifyDispatchRejection({ reason }) + if (rejection.kind === 'writeFailed') { return "Couldn't reach the agent. Your message was not sent — Retry to send it again." } - // Any other reason we minted is an internal cause with no user-facing meaning; - // only a provider's own explanation is worth reading verbatim. - return dispatchRejectionReasonIsInternal(reason) - ? 'Orca could not send your message — Retry to send it again.' - : reason + // A legacy marker is an internal cause with no user-facing meaning; any other reason is a + // sentence written to be read — the provider's, or the host's own. + return rejection.kind ? 'Orca could not send your message — Retry to send it again.' : reason } export function disposeStructuredAgentSessionSendResult( @@ -190,6 +186,19 @@ export function disposeStructuredAgentSessionSendResult( retryWithFreshClientMessageId: null } } + // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile + // drops it, with no notice. + if ( + submission.dispatchState === 'rejected' && + classifyDispatchRejection(submission).category === 'withdrawn' + ) { + return { + entries: dropEntry(input), + error: null, + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null + } + } if (submission.dispatchState === 'rejected') { return { entries: replaceEntryState(input, 'rejected'), From c38220fd382e40c791fcefd0b6690a178cdd65e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:17:16 -0700 Subject: [PATCH 058/122] test(native-chat): pin provider diagnostics where they are composed The Claude exit status and stderr, the Codex stderr tail and Codex's JSON-RPC message are each checked at the place Orca composes its own error around them, so the typed detail is proven to come from the provider's value and never from Orca's wording. --- .../claude-stream-json-connection.test.ts | 6 ++++++ .../codex/codex-app-server-connection.test.ts | 4 ++++ .../codex/codex-app-server-exit-error.test.ts | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 src/main/codex/codex-app-server-exit-error.test.ts diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 51ffa085163..946cf08e1c4 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' @@ -592,6 +593,11 @@ describe('Claude stream-json connection', () => { await until(() => exit, 'the exit error') // The status and stderr are the only diagnostic a refused start leaves behind. expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) + // Kept apart from Orca's wording where it is composed, and marked as log text. + expect(providerDiagnosticOf(exit)).toEqual({ + text: expect.stringMatching(/^\(code 1\)\n.*claude: not signed in/s), + audience: 'log' + }) expect(connection.closed).toBe(true) // Stderr-triggered capture can win or lose the race with this real child's exit. const closed = await connection.close() diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index d67f2ebecc6..5c630b5fc15 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { PassThrough } from 'node:stream' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { @@ -254,6 +255,9 @@ describe('openCodexAppServerConnection', () => { expect(isCodexAppServerRequestError(refusal)).toBe(true) expect((refusal as Error).message).toContain('bad params') + // Codex's own words, apart from Orca's prefix, for a person to read. + expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'person' }) + expect(providerDiagnosticOf(missing)).toBeUndefined() expect(isCodexAppServerUnsupportedError(missing)).toBe(true) expect(isCodexAppServerRequestError(missing)).toBe(false) await connection.close() diff --git a/src/main/codex/codex-app-server-exit-error.test.ts b/src/main/codex/codex-app-server-exit-error.test.ts new file mode 100644 index 00000000000..63ec9a63743 --- /dev/null +++ b/src/main/codex/codex-app-server-exit-error.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import { buildCodexAppServerExitError } from './codex-app-server-exit-error' + +describe('buildCodexAppServerExitError', () => { + it("keeps the stderr tail apart from Orca's wording, as log text", () => { + const error = buildCodexAppServerExitError(' thread panicked at main.rs:4 ') + expect(error.message).toBe('codex app-server connection ended: thread panicked at main.rs:4') + expect(providerDiagnosticOf(error)).toEqual({ + text: 'thread panicked at main.rs:4', + audience: 'log' + }) + }) + + it('carries no diagnostic when a cause, not the stderr, explains the end', () => { + const error = buildCodexAppServerExitError('ignored', new Error('spawn codex ENOENT')) + expect(providerDiagnosticOf(error)).toBeUndefined() + expect(providerDiagnosticOf(buildCodexAppServerExitError(''))).toBeUndefined() + }) +}) From 3592e14d61e3ad50a7aadc166de8620e535de10a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:32:11 -0700 Subject: [PATCH 059/122] fix(native-chat): an attachment Orca refuses says which limit it broke The content check's refusals (20 images, 5 MB per image, 20 MB in total, supported types) are written for a person, but the rejection writer replaced them all with one generic sentence. Each refusal now carries its own sentence, in MB rather than bytes, and the writer records it beside kind attachmentInvalid. Only an attachment that could not be read keeps the generic sentence. --- ...ured-dispatch-attachment-rejection.test.ts | 118 ++++++++++++++++++ .../claude-structured-dispatch-content.ts | 43 +++++-- .../claude/claude-structured-dispatch.test.ts | 66 ---------- src/main/claude/claude-structured-dispatch.ts | 8 +- 4 files changed, 153 insertions(+), 82 deletions(-) create mode 100644 src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts diff --git a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts new file mode 100644 index 00000000000..6db058e2286 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts @@ -0,0 +1,118 @@ +// What a person reads when Orca refuses a message's attachments before sending it. + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' + +describe('Claude structured dispatch attachment rejections', () => { + it('rejects more than twenty URL images before sending', async () => { + const session = sessionFor() + const body = userMessage( + Array.from({ length: 21 }, (_, index) => ({ + type: 'image-ref' as const, + url: `https://example.test/${index}.png` + })) + ) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', + rejection: { kind: 'attachmentInvalid' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects local images whose aggregate size exceeds twenty MiB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) + try { + const paths = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const path = join(directory, `${index}.png`) + await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) + return path + }) + ) + const session = sessionFor() + const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'The images on this message add up to more than 20 MB, so the message was not sent.', + rejection: { kind: 'attachmentInvalid' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image by actual bytes read beyond the per-image cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'oversized.png') + await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'An image on this message is empty or larger than 5 MB, so the message was not sent.', + rejection: { kind: 'attachmentInvalid' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('names an unsupported image type in words a person can act on', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'picture.bmp') + await writeFile(path, Buffer.alloc(64)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.', + rejection: { kind: 'attachmentInvalid' } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects an attachment it cannot read with the generic sentence and no path', async () => { + const session = sessionFor() + const path = join(tmpdir(), 'orca-claude-image-missing', 'gone.png') + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "An attachment on this message couldn't be read, so the message was not sent.", + rejection: { kind: 'attachmentUnreadable' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 3e57ab1b8e3..29fbff2ad80 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -7,15 +7,20 @@ import { claudeRecord } from './claude-structured-item-translation' /** Orca refused the message's content, as opposed to failing to read an attachment. */ export class ClaudeDispatchContentError extends Error { - constructor(message: string) { + /** What the person reads on the rejected message; `message` stays for logs. */ + readonly sentence: string + + constructor(message: string, sentence: string) { super(message) this.name = 'ClaudeDispatchContentError' + this.sentence = sentence } } -const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +const BYTES_PER_MB = 1024 * 1024 +const MAX_IMAGE_BYTES = 5 * BYTES_PER_MB const MAX_IMAGE_COUNT = 20 -const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_TOTAL_IMAGE_BYTES = 20 * BYTES_PER_MB const MAX_REPLAY_CONTENT_KEY_BYTES = 256 type ImageBudget = { @@ -28,11 +33,15 @@ export async function readClaudeImage(path: string, openImpl: typeof open = open try { const invalidImage = (): Error => new ClaudeDispatchContentError( - `Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes` + `Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`, + `An image on this message is empty or larger than ${MAX_IMAGE_BYTES / BYTES_PER_MB} MB, so the message was not sent.` ) const info = await file.stat() if (!info.isFile()) { - throw new ClaudeDispatchContentError('Claude image must be a file') + throw new ClaudeDispatchContentError( + 'Claude image must be a file', + "An image on this message isn't a file, so the message was not sent." + ) } if (info.size > MAX_IMAGE_BYTES) { throw invalidImage() @@ -73,26 +82,32 @@ async function imageContent( budget.count += 1 if (budget.count > MAX_IMAGE_COUNT) { throw new ClaudeDispatchContentError( - `Claude messages support at most ${MAX_IMAGE_COUNT} images` + `Claude messages support at most ${MAX_IMAGE_COUNT} images`, + `Claude accepts at most ${MAX_IMAGE_COUNT} images in one message, so this message was not sent.` ) } if (block.url) { return { type: 'image', source: { type: 'url', url: block.url } } } if (!block.path) { - throw new ClaudeDispatchContentError('image reference has neither a path nor a URL') + throw new ClaudeDispatchContentError( + 'image reference has neither a path nor a URL', + 'An image on this message has no file to send, so the message was not sent.' + ) } const data = await readClaudeImage(block.path) budget.localBytes += data.byteLength if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { throw new ClaudeDispatchContentError( - `Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes` + `Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`, + `The images on this message add up to more than ${MAX_TOTAL_IMAGE_BYTES / BYTES_PER_MB} MB, so the message was not sent.` ) } const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] if (!mediaType) { throw new ClaudeDispatchContentError( - `Claude does not support the image type ${extname(block.path)}` + `Claude does not support the image type ${extname(block.path)}`, + 'Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.' ) } return { @@ -115,7 +130,10 @@ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { - throw new ClaudeDispatchContentError('Claude dispatch accepts only user messages') + throw new ClaudeDispatchContentError( + 'Claude dispatch accepts only user messages', + "This message can't be sent to the agent." + ) } const images: unknown[] = [] const texts: string[] = [] @@ -131,7 +149,10 @@ export async function claudeDispatchMessageContent( // text blocks would silently discard every one but the last. const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { - throw new ClaudeDispatchContentError('Claude dispatch requires text or an image') + throw new ClaudeDispatchContentError( + 'Claude dispatch requires text or an image', + 'This message is empty, so it was not sent.' + ) } return content } diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 758028440bf..b299c94a5a8 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -692,72 +692,6 @@ describe('Claude structured dispatch image limits', () => { }) }) - it('rejects more than twenty URL images before sending', async () => { - const session = sessionFor() - const body = userMessage( - Array.from({ length: 21 }, (_, index) => ({ - type: 'image-ref' as const, - url: `https://example.test/${index}.png` - })) - ) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: "An attachment on this message can't be sent to the agent.", - rejection: { kind: 'attachmentInvalid' } - }) - expect(session.connection.send).not.toHaveBeenCalled() - }) - - it('rejects local images whose aggregate size exceeds twenty MiB', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) - try { - const paths = await Promise.all( - Array.from({ length: 5 }, async (_, index) => { - const path = join(directory, `${index}.png`) - await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) - return path - }) - ) - const session = sessionFor() - const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: "An attachment on this message can't be sent to the agent.", - rejection: { kind: 'attachmentInvalid' } - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - - it('rejects a local image by actual bytes read beyond the per-image cap', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) - try { - const path = join(directory, 'oversized.png') - await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) - const session = sessionFor() - const body = userMessage([{ type: 'image-ref', path }]) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: "An attachment on this message can't be sent to the agent.", - rejection: { kind: 'attachmentInvalid' } - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - it('allocates local image reads from the file size, not the maximum cap', async () => { const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 4ecb5813fb8..d74da807b00 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -247,11 +247,9 @@ export async function dispatchClaudeTurn( // Orca's own refusal of the content, or an attachment it could not read; never the provider. return { state: 'rejected', - ...agentSessionFailureRejection( - agentSessionFailureFact( - error instanceof ClaudeDispatchContentError ? 'attachmentInvalid' : 'attachmentUnreadable' - ) - ) + ...(error instanceof ClaudeDispatchContentError + ? { reason: error.sentence, rejection: agentSessionFailureFact('attachmentInvalid') } + : agentSessionFailureRejection(agentSessionFailureFact('attachmentUnreadable'))) } } if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { From 0ce944afc3fe89b681987e704531237f6f653874 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:34:28 -0700 Subject: [PATCH 060/122] fix(native-chat): the chat tab table refuses with a typed cause Showing a chat tab refused with bare Error('agent_session_conflict') and Error('agent_session_identity_required'), the only chat-reachable refusals still thrown without a cause (opening a chat from history can reach the second when the chat is removed mid-open). Both now throw the typed refusal; wire code and message are unchanged. --- ...ent-session-record-store-tab-table.test.ts | 29 +++++++++++++++++++ src/main/runtime/agent-session-tab-table.ts | 5 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index e0ecfec2835..838efa41e19 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -9,6 +9,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { AgentSessionRecordStore } from './agent-session-record-store' import { agentSessionStorePath } from './agent-session-record-store-file' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' @@ -94,6 +95,34 @@ describe('chat tab table', () => { expect(store.getRecord('session-beta')).toBeNull() }) + it('refuses showing a tab with the situation typed, as every chat refusal is', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + await store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + + // The message stays the code: readers of a thrown refusal treat it as one. + await expect( + store.setSessionTabVisibility('session-beta', true, 'tab-alpha') + ).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_conflict' && + error.refusal.cause === 'tabIdTaken' + ) + await expect(store.setSessionTabVisibility('session-gone', true)).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_identity_required' && + error.refusal.cause === 'recordMissing' + ) + }) + it('frees a reserved id once its chat is hidden', async () => { const store = await open() await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 3855d431989..6e95f7e51ae 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -1,4 +1,5 @@ import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import type { AgentSessionStoreState } from './agent-session-record-store-file' @@ -92,7 +93,7 @@ export class AgentSessionTabTable { private put(tabId: string, sessionId: string): void { const owner = this.sessionByTab.get(tabId) if (owner !== undefined && owner !== sessionId) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', 'tabIdTaken') } this.hide(sessionId) this.sessionByTab.set(tabId, sessionId) @@ -120,7 +121,7 @@ export function setAgentSessionTabVisibility( tabId?: string ): void { if (visible && !state.records.has(sessionId)) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', 'recordMissing') } state.sessionTabs ??= new AgentSessionTabTable() if (visible) { From 0188573b1969a06d7391287ef27d08220c9cba21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:41:31 -0700 Subject: [PATCH 061/122] fix(native-chat): a compaction Codex refuses up front keeps Codex's words When Codex refused thread/compact/start, the adapter passed on only Orca's wrapped error text and dropped Codex's own message, so the chat's row read just "Compaction failed." The refusal now carries Codex's message as the failure detail, as a compaction that fails later already did. --- ...odex-structured-compaction-refusal.test.ts | 28 +++++++++++++++++++ .../codex/codex-structured-session-adapter.ts | 3 +- .../structured-session-compaction.ts | 11 ++++++-- src/shared/agent-session-failure.ts | 2 +- 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 src/main/codex/codex-structured-compaction-refusal.test.ts diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts new file mode 100644 index 00000000000..17b9e7af20b --- /dev/null +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -0,0 +1,28 @@ +// A compaction Codex refuses up front keeps Codex's own words for the chat's failure row. + +import { describe, expect, it } from 'vitest' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { acquired, fakeCodex } from './codex-structured-session-adapter-fixture' + +describe('Codex compaction refused at the request', () => { + it("carries Codex's message as the detail, apart from Orca's wrapper", async () => { + const codex = fakeCodex({ + 'thread/compact/start': () => { + throw new CodexAppServerRequestError( + 'thread/compact/start', + -32600, + 'codex app-server thread/compact/start failed: thread has nothing to compact', + 'thread has nothing to compact' + ) + } + }) + const adapter = await acquired(codex) + + await expect( + adapter.compact({ turnId: 'compact-1', sessionId: 'session-1', fence: 7 }) + ).resolves.toEqual({ + error: 'codex app-server thread/compact/start failed: thread has nothing to compact', + detail: { text: 'thread has nothing to compact', audience: 'person' } + }) + }) +}) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index b9affd8bd00..8907539cc01 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -259,7 +259,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) .catch((error) => { if (isCodexAppServerRequestError(error)) { - return { error: error.message } + const detail = error.providerDiagnostic + return { error: error.message, ...(detail ? { detail } : {}) } } throw error }) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts index 0a34324d6f6..b2b0b2e0771 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -1,4 +1,8 @@ -import { providerDiagnostic, type ProviderDiagnostic } from '../../../shared/agent-session-failure' +import { + isProviderDiagnostic, + providerDiagnostic, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' /** `error` is Orca's account of a failed compaction; `detail` is the provider's own words, when it * gave any. */ @@ -69,7 +73,10 @@ export class StructuredSessionCompaction { try { const admission = record(await invoke()) if (typeof admission.error === 'string') { - this.pending.get(sessionId)?.finish({ error: admission.error }) + const detail = isProviderDiagnostic(admission.detail) ? admission.detail : undefined + this.pending + .get(sessionId) + ?.finish({ error: admission.error, ...(detail ? { detail } : {}) }) } return await completion } catch (error) { diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 8e177a0df07..55bcd3d484c 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -89,7 +89,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic { +export function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic { return ( isRecord(value) && typeof value.text === 'string' && From 570eff540c1261c1bd01043a894deba35b45a2e7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:42:50 -0700 Subject: [PATCH 062/122] fix(native-chat): an unreadable chat record no longer promises an update fixes it The recordUnreadable copy said a newer Orca saved the chat, but the store marks a record unreadable for damage and key mismatches too, where updating does nothing. The sentence now says Orca can't read it and gives both next steps. --- ...tructured-agent-session-refusal-cause.test.ts | 16 ++++++++++++++++ .../structured-agent-session-refusal-message.ts | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts index f62086d3b76..9bb3b2d290e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts @@ -105,6 +105,22 @@ describe('the store fallback copy', () => { expect(refusal.message).toContain('4242') }) + it('does not promise an update fixes a record this build cannot read', () => { + // Unreadable covers a damaged record as well as one a newer build wrote. + expect( + classifyStoreFailure( + agentSessionRefusalError('execution_owner_reconciling', 'recordUnreadable'), + null, + null + ) + ).toEqual({ + code: 'execution_owner_reconciling', + cause: 'recordUnreadable', + message: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." + }) + }) + it('names the latch for a bare code an older path still throws', () => { expect( classifyStoreFailure(new Error('agent_session_conflict'), null, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts index ed9616bde67..5cbb2d0b974 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -48,7 +48,9 @@ const SITUATION_MESSAGES: Partial> = { tabIdTaken: 'Another chat already uses this tab. Try again.', conversationHeldElsewhere: 'Another chat is already working in this conversation. Open that chat to continue.', - recordUnreadable: 'A newer version of Orca saved this chat. Update Orca to open it.' + // Also a damaged record, which no update opens. + recordUnreadable: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." } /** From dd05f06621ecb9cafac07681732f710464c4c120 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:45:42 -0700 Subject: [PATCH 063/122] fix(native-chat): a restart or a close leaves released clients a sentence, not a marker host_restarted_before_delivery and provider_closed_before_delivery are not in the markers released desktop and mobile builds hide, so they printed raw on every rejected message a restart or a chat close left. Neither marker has shipped. New rows carry a sentence plus kind hostRestarted or chatClosed, as not_delivered already did; the classifier still reads both markers, and the verdict for both stays no-failure. --- ...red-agent-session-accept-then-deliver.test.ts | 10 +++++----- .../structured-agent-session-failure-text.ts | 6 ++++-- ...d-agent-session-provider-child-record.test.ts | 6 +++--- ...ured-agent-session-dispatch-rejection.test.ts | 16 ++++++++++++++++ ...tructured-agent-session-dispatch-rejection.ts | 10 ++++++---- 5 files changed, 34 insertions(+), 14 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 d8b7b50af13..dbce62254c4 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 @@ -12,8 +12,8 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_HOST_RESTARTED, - DISPATCH_REJECTED_PROVIDER_CLOSED + DISPATCH_REJECTION_HOST_RESTARTED, + DISPATCH_REJECTION_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' @@ -462,7 +462,7 @@ describe('what an earlier host process left behind', () => { expect(submission('queued')).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_HOST_RESTARTED + ...DISPATCH_REJECTION_HOST_RESTARTED }) expect(dispatch).not.toHaveBeenCalled() }) @@ -599,7 +599,7 @@ describe('an eviction between acceptance and handover', () => { expect(await reopened(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...DISPATCH_REJECTION_PROVIDER_CLOSED }) expect(dispatch).not.toHaveBeenCalled() }) @@ -623,7 +623,7 @@ describe('an eviction between acceptance and handover', () => { expect(await reopened(queued)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...DISPATCH_REJECTION_PROVIDER_CLOSED }) expect(submission(handed)).toMatchObject({ dispatchState: 'unknown' }) expect(dispatch).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 54615124021..9c0d846e1a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -20,7 +20,9 @@ import { type AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' import { + DISPATCH_REJECTION_HOST_RESTARTED, DISPATCH_REJECTION_NOT_DELIVERED, + DISPATCH_REJECTION_PROVIDER_CLOSED, type AgentJournalDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' @@ -91,8 +93,8 @@ const FAILURE_SENTENCES = { queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.', writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", cancelled: () => 'This message was withdrawn before the agent started it.', - chatClosed: () => 'The chat closed before this message was sent.', - hostRestarted: () => 'Orca restarted before this message was sent.', + chatClosed: () => DISPATCH_REJECTION_PROVIDER_CLOSED.reason, + hostRestarted: () => DISPATCH_REJECTION_HOST_RESTARTED.reason, notDelivered: () => DISPATCH_REJECTION_NOT_DELIVERED.reason, compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', 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 c53c858a36b..6d5836a6d3e 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 @@ -14,7 +14,7 @@ import type { } from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_PROVIDER_CLOSED + DISPATCH_REJECTION_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -568,7 +568,7 @@ describe('a quit with a message still queued', () => { expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...DISPATCH_REJECTION_PROVIDER_CLOSED }) }) @@ -596,7 +596,7 @@ describe('a quit with a message still queued', () => { expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...DISPATCH_REJECTION_PROVIDER_CLOSED }) }) }) diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index a5e9a0edf2b..63a6cb12694 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -7,6 +7,8 @@ import { DISPATCH_REJECTED_HOST_RESTARTED, DISPATCH_REJECTED_PROVIDER_CLOSED, DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTION_HOST_RESTARTED, + DISPATCH_REJECTION_PROVIDER_CLOSED, dispatchWriteFailureReason, isWriteFailureSubmission } from './structured-agent-session-dispatch-rejection' @@ -58,6 +60,20 @@ describe('classifyDispatchRejection', () => { ).toMatchObject({ category: 'transport', verdict: 'failure' }) }) + it('writes a sentence, not a marker released clients would print, for a restart or a close', () => { + for (const [written, kind] of [ + [DISPATCH_REJECTION_HOST_RESTARTED, 'hostRestarted'], + [DISPATCH_REJECTION_PROVIDER_CLOSED, 'chatClosed'] + ] as const) { + expect(written.reason).not.toMatch(/^[a-z_]+$/) + expect(classifyDispatchRejection(written)).toEqual({ + category: 'undelivered', + verdict: null, + kind + }) + } + }) + it('falls back to the reason when a newer host wrote a kind this build does not know', () => { const rejection = JSON.parse('{"kind":"futureKind"}') expect( diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 98143b7b856..90d2128c21a 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -27,10 +27,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. */ +/** Legacy marker: accepted by a host process that ended before handing it to any provider. + * Read only; released clients print it raw, so new rows carry a sentence. */ export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery' -/** Accepted, then the provider was closed before the message was handed to it. */ +/** Legacy marker: accepted, then the provider was closed before the message was handed to it. + * Read only, like the one above. */ export const DISPATCH_REJECTED_PROVIDER_CLOSED = 'provider_closed_before_delivery' export function dispatchWriteFailureReason(error: unknown): string { @@ -51,12 +53,12 @@ export const DISPATCH_REJECTION_CANCELLED: AgentJournalDispatchRejection = { } export const DISPATCH_REJECTION_HOST_RESTARTED: AgentJournalDispatchRejection = { - reason: DISPATCH_REJECTED_HOST_RESTARTED, + reason: 'Orca restarted before this message was sent.', rejection: { kind: 'hostRestarted' } } export const DISPATCH_REJECTION_PROVIDER_CLOSED: AgentJournalDispatchRejection = { - reason: DISPATCH_REJECTED_PROVIDER_CLOSED, + reason: 'The chat closed before this message was sent.', rejection: { kind: 'chatClosed' } } From 34a1e43c2118135c533f1c9dd4e4913a3d35617a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:48:30 -0700 Subject: [PATCH 064/122] fix(native-chat): log why an attachment could not be read The rejected message now says only that the attachment couldn't be read, so the error that said why (a missing file, a permission, or an unexpected throw) went nowhere. It is logged instead. The content rejection moves beside the content check that owns its errors. --- ...tructured-dispatch-attachment-rejection.test.ts | 9 ++++++++- .../claude/claude-structured-dispatch-content.ts | 14 ++++++++++++++ src/main/claude/claude-structured-dispatch.ts | 11 ++--------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts index 6db058e2286..d330ee8e684 100644 --- a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts +++ b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' @@ -100,6 +100,7 @@ describe('Claude structured dispatch attachment rejections', () => { }) it('rejects an attachment it cannot read with the generic sentence and no path', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const session = sessionFor() const path = join(tmpdir(), 'orca-claude-image-missing', 'gone.png') @@ -114,5 +115,11 @@ describe('Claude structured dispatch attachment rejections', () => { rejection: { kind: 'attachmentUnreadable' } }) expect(session.connection.send).not.toHaveBeenCalled() + // The row drops the error, so the log is the only place left to find why. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] attachment could not be read:', + expect.objectContaining({ code: 'ENOENT' }) + ) + warn.mockRestore() }) }) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 29fbff2ad80..9b0beb0d340 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,6 +3,9 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { claudeRecord } from './claude-structured-item-translation' /** Orca refused the message's content, as opposed to failing to read an attachment. */ @@ -17,6 +20,17 @@ export class ClaudeDispatchContentError extends Error { } } +/** Why a message whose content could not be built was not sent: Orca's own refusal of it, in the + * words that refusal carries, or an attachment it could not read. Never the provider. */ +export function claudeDispatchContentRejection(error: unknown): AgentJournalDispatchRejection { + if (error instanceof ClaudeDispatchContentError) { + return { reason: error.sentence, rejection: agentSessionFailureFact('attachmentInvalid') } + } + // The row says only that it could not be read; why belongs in the log. + console.warn('[claude-dispatch] attachment could not be read:', error) + return agentSessionFailureRejection(agentSessionFailureFact('attachmentUnreadable')) +} + const BYTES_PER_MB = 1024 * 1024 const MAX_IMAGE_BYTES = 5 * BYTES_PER_MB const MAX_IMAGE_COUNT = 20 diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index d74da807b00..b5000a8a27c 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -18,8 +18,8 @@ import type { } from './claude-structured-session-state' import { readClaudeFrameString } from './claude-structured-init-proof' import { - ClaudeDispatchContentError, claudeDispatchContentKey, + claudeDispatchContentRejection, claudeDispatchInvokesSlashCommand, claudeDispatchMessageContent } from './claude-structured-dispatch-content' @@ -30,7 +30,6 @@ import { dispatchQueueFullRejection, dispatchWriteFailureRejection } from '../../shared/structured-agent-session-dispatch-rejection' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { claudeUnwrittenUserMessageError, @@ -244,13 +243,7 @@ export async function dispatchClaudeTurn( try { content = await claudeDispatchMessageContent(input.body) } catch (error) { - // Orca's own refusal of the content, or an attachment it could not read; never the provider. - return { - state: 'rejected', - ...(error instanceof ClaudeDispatchContentError - ? { reason: error.sentence, rejection: agentSessionFailureFact('attachmentInvalid') } - : agentSessionFailureRejection(agentSessionFailureFact('attachmentUnreadable'))) - } + return { state: 'rejected', ...claudeDispatchContentRejection(error) } } if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { return { state: 'rejected', ...dispatchQueueFullRejection(DISPATCH_REJECTED_QUEUE_FULL) } From 6cd9a9c110ec3cd79098bff71ed34f39ccb1e9da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:49:15 -0700 Subject: [PATCH 065/122] refactor(native-chat): drop an unused thrown-refusal cause reader It had no callers, and its comment claimed it looked through wrappers, which it did not. --- src/shared/agent-session-wire-refusals.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/shared/agent-session-wire-refusals.ts b/src/shared/agent-session-wire-refusals.ts index b59995825ed..3e686a027ee 100644 --- a/src/shared/agent-session-wire-refusals.ts +++ b/src/shared/agent-session-wire-refusals.ts @@ -189,10 +189,3 @@ export function agentSessionRefusalError( export function isAgentSessionRefusalError(error: unknown): error is AgentSessionRefusalError { return error instanceof AgentSessionRefusalError } - -/** The situation a thrown refusal named, whatever wrapped it. */ -export function thrownAgentSessionRefusalCause( - error: unknown -): AgentSessionRefusalCause | undefined { - return error instanceof AgentSessionRefusalError ? error.refusal.cause : undefined -} From 44672f5898a11febcc7f0426733c485a74a99591 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:14:53 -0700 Subject: [PATCH 066/122] fix(native-chat): a compaction the provider never confirmed is recorded as unconfirmed, not failed --- .../structured-conversation-command.test.ts | 20 ++++++++++- .../structured-conversation-command.ts | 9 +++-- .../structured-session-compaction.test.ts | 36 ++++++++++++++++++- .../structured-session-compaction.ts | 31 ++++++++++------ 4 files changed, 81 insertions(+), 15 deletions(-) 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 63417b3f135..a7b898e429e 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 @@ -148,13 +148,31 @@ describe('host conversation commands', () => { }) it("keeps Orca's own compaction error out of the sentence", async () => { - compact.mockResolvedValue({ error: 'The provider exited during compaction.' }) + compact.mockResolvedValue({ error: 'Compaction did not complete.' }) expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, value: { error: 'Compaction failed.', failure: { kind: 'compactionFailed' } } }) }) + it('records a compaction the provider never confirmed as unconfirmed, not failed', async () => { + compact.mockResolvedValue({ + error: 'The provider exited during compaction.', + unconfirmed: true + }) + const failure = { kind: 'compactionUnconfirmed' } + expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + ok: true, + value: { error: 'Compaction completion is unconfirmed.', failure } + }) + const rows = host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure + }) + }) + it('keeps an unknown compaction from being executed again', async () => { compact.mockRejectedValue(new Error('connection lost')) const params = commandParams('compact') 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 8723a4372dc..10bd7198e5e 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 @@ -27,12 +27,15 @@ import { agentSessionRefusalReference } from '../../../shared/agent-session-wire import { agentSessionFailureText } from './structured-agent-session-failure-text' import type { StructuredSessionCompactionResult } from './structured-session-compaction' -/** A failed compaction, keeping only what the provider wrote for a person. */ +/** A compaction that did not succeed, keeping only what the provider wrote for a person. */ function compactionFailure( result: StructuredSessionCompactionResult ): AgentSessionFailureFact | undefined { - return result.error === undefined - ? undefined + if (result.error === undefined) { + return undefined + } + return result.unconfirmed + ? agentSessionFailureFact('compactionUnconfirmed') : agentSessionFailureFact('compactionFailed', { detail: result.detail }) } diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts index 7ee5deb260b..2e305391211 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts @@ -80,7 +80,10 @@ describe('structured compaction lifecycle', () => { const tracker = new StructuredSessionCompaction() const pending = tracker.run('s', 'p', async () => {}) tracker.ended('s') - await expect(pending).resolves.toEqual({ error: 'The provider exited during compaction.' }) + await expect(pending).resolves.toEqual({ + error: 'The provider exited during compaction.', + unconfirmed: true + }) const next = tracker.run('s', 'p', async () => { tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) @@ -111,6 +114,37 @@ describe('structured compaction lifecycle', () => { const result = tracker.run('s', 't', async () => ({})) tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'completed' } }) + await expect(result).resolves.toEqual({ + error: 'Compaction was not confirmed by the provider.', + unconfirmed: true + }) + }) + + it('fails, not merely unconfirmed, a Codex compaction turn that did not complete', async () => { + const tracker = new StructuredSessionCompaction() + const result = tracker.run('s', 't', async () => ({})) + tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) + tracker.codex('s', 'turn/completed', { + threadId: 't', + turn: { id: 'c', status: 'interrupted' } + }) + await expect(result).resolves.toEqual({ error: 'Compaction did not complete.' }) + }) + + it('leaves a Claude turn that ended without confirming compaction unconfirmed', async () => { + const tracker = new StructuredSessionCompaction() + const result = tracker.run('s', 'p', async () => {}) + tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) + await expect(result).resolves.toEqual({ + error: 'Compaction was not confirmed by the provider.', + unconfirmed: true + }) + }) + + it('fails a Claude compaction whose turn ended in error', async () => { + const tracker = new StructuredSessionCompaction() + const result = tracker.run('s', 'p', async () => {}) + tracker.claude('s', { type: 'result', subtype: 'error_during_execution', session_id: 'p' }) await expect(result).resolves.toEqual({ error: 'Compaction did not complete.' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts index b2b0b2e0771..80fb94a719f 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -4,9 +4,13 @@ import { type ProviderDiagnostic } from '../../../shared/agent-session-failure' -/** `error` is Orca's account of a failed compaction; `detail` is the provider's own words, when it - * gave any. */ -export type StructuredSessionCompactionResult = { error?: string; detail?: ProviderDiagnostic } +/** `error` is Orca's account of a compaction that did not succeed; `unconfirmed` when the provider + * never said whether it compacted, so it may have; `detail` is the provider's own words, if any. */ +export type StructuredSessionCompactionResult = { + error?: string + unconfirmed?: true + detail?: ProviderDiagnostic +} type PendingCompaction = { identity: string @@ -103,7 +107,9 @@ export class StructuredSessionCompaction { } ended(sessionId: string): void { - this.pending.get(sessionId)?.finish({ error: 'The provider exited during compaction.' }) + this.pending + .get(sessionId) + ?.finish({ error: 'The provider exited during compaction.', unconfirmed: true }) } codex(sessionId: string, method: string, value: unknown): void { @@ -123,8 +129,10 @@ export class StructuredSessionCompaction { const error = record(turn.error).message const detail = typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined pending.finish( - turn.status === 'completed' && pending.compacted - ? {} + turn.status === 'completed' + ? pending.compacted + ? {} + : { error: 'Compaction was not confirmed by the provider.', unconfirmed: true } : { error: typeof error === 'string' ? error : 'Compaction did not complete.', ...(detail ? { detail } : {}) @@ -156,10 +164,13 @@ export class StructuredSessionCompaction { ) { pending.error ??= 'Compaction did not complete.' } - const error = - pending.error ?? - (pending.compacted ? undefined : 'Compaction was not confirmed by the provider.') - pending.finish(error ? { error, ...(pending.detail ? { detail: pending.detail } : {}) } : {}) + pending.finish( + pending.error !== undefined + ? { error: pending.error, ...(pending.detail ? { detail: pending.detail } : {}) } + : pending.compacted + ? {} + : { error: 'Compaction was not confirmed by the provider.', unconfirmed: true } + ) } } } From ac5e9c8fd1c13bb1cdf96fec83f25cc371956567 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:17:34 -0700 Subject: [PATCH 067/122] fix(native-chat): an empty or non-user message is not recorded as a bad attachment --- ...ured-dispatch-attachment-rejection.test.ts | 27 ++++++++++++++++++- .../claude-structured-dispatch-content.ts | 17 +++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts index d330ee8e684..f0502efc6c7 100644 --- a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts +++ b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts @@ -1,4 +1,4 @@ -// What a person reads when Orca refuses a message's attachments before sending it. +// What a person reads when Orca refuses a message's content before sending it. import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -99,6 +99,31 @@ describe('Claude structured dispatch attachment rejections', () => { } }) + it('does not blame an attachment for a message no Orca client sends', async () => { + const session = sessionFor() + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '' }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'This message is empty, so it was not sent.', + rejection: { kind: 'hostFault' } + }) + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: { ...userMessage([{ type: 'text', text: 'hi' }]), role: 'assistant' } + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "This message can't be sent to the agent.", + rejection: { kind: 'hostFault' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + it('rejects an attachment it cannot read with the generic sentence and no path', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const session = sessionFor() diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 9b0beb0d340..1b84976e94f 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -12,11 +12,18 @@ import { claudeRecord } from './claude-structured-item-translation' export class ClaudeDispatchContentError extends Error { /** What the person reads on the rejected message; `message` stays for logs. */ readonly sentence: string + /** `hostFault`: a body no Orca client sends, so the fault is Orca's, not an attachment's. */ + readonly kind: 'attachmentInvalid' | 'hostFault' - constructor(message: string, sentence: string) { + constructor( + message: string, + sentence: string, + kind: 'attachmentInvalid' | 'hostFault' = 'attachmentInvalid' + ) { super(message) this.name = 'ClaudeDispatchContentError' this.sentence = sentence + this.kind = kind } } @@ -24,7 +31,7 @@ export class ClaudeDispatchContentError extends Error { * words that refusal carries, or an attachment it could not read. Never the provider. */ export function claudeDispatchContentRejection(error: unknown): AgentJournalDispatchRejection { if (error instanceof ClaudeDispatchContentError) { - return { reason: error.sentence, rejection: agentSessionFailureFact('attachmentInvalid') } + return { reason: error.sentence, rejection: agentSessionFailureFact(error.kind) } } // The row says only that it could not be read; why belongs in the log. console.warn('[claude-dispatch] attachment could not be read:', error) @@ -146,7 +153,8 @@ export async function claudeDispatchMessageContent( if (body.role !== 'user') { throw new ClaudeDispatchContentError( 'Claude dispatch accepts only user messages', - "This message can't be sent to the agent." + "This message can't be sent to the agent.", + 'hostFault' ) } const images: unknown[] = [] @@ -165,7 +173,8 @@ export async function claudeDispatchMessageContent( if (content.length === 0) { throw new ClaudeDispatchContentError( 'Claude dispatch requires text or an image', - 'This message is empty, so it was not sent.' + 'This message is empty, so it was not sent.', + 'hostFault' ) } return content From 20d35c9f779253c871feeaae7a45514cbf5b971f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:18:49 -0700 Subject: [PATCH 068/122] fix(native-chat): an undelivered preamble's error ends in one period --- ...estration-structured-worker-session.test.ts | 18 ++++++++++++++++++ .../orchestration-structured-worker-session.ts | 9 +++++++-- 2 files changed, 25 insertions(+), 2 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 86568a0435f..be864f61df0 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 @@ -316,6 +316,24 @@ describe('structured worker dispatch preamble', () => { expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(false) }) + it('ends the message with one period whether the reason is a sentence or a marker', async () => { + const sentence = await send( + hostWithSubmission({ + dispatchState: 'rejected', + reason: 'The provider stopped before this message was sent.' + }) + ).catch((thrown: unknown) => thrown) + expect((sentence as Error).message).toBe( + 'The dispatch preamble was not delivered: The provider stopped before this message was sent.' + ) + const marker = await send( + hostWithSubmission({ dispatchState: 'unknown', reason: 'provider child exited' }) + ).catch((thrown: unknown) => thrown) + expect((marker as Error).message).toBe( + 'The dispatch preamble was submitted but not acknowledged (unknown): provider child exited.' + ) + }) + it('reports a refused transport write as undelivered, never as unknown', async () => { // The state a provably-unwritten frame now settles. Nothing reached the provider, // so there is no running turn for a coordinator to go and look at. 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 8c7baf5ea83..d27eef8a72f 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -214,6 +214,11 @@ export async function discardStructuredWorkerSession( retireSettledStructuredWorkerTab(sessionId, runtime) } +/** A submission reason as a clause: host sentences end in a period, legacy markers do not. */ +function reasonClause(reason: string | null | undefined): string { + return (reason ?? 'no reason given').replace(/[.\s]+$/, '') +} + /** What a preamble send reads of the host. */ type StructuredWorkerPreambleHost = Pick< StructuredAgentSessionHost, @@ -278,7 +283,7 @@ export async function sendStructuredWorkerPreamble(args: { 'dispatch_preamble_undelivered', `The dispatch preamble was not delivered${ submission.rejection ? ` (${submission.rejection.kind})` : '' - }: ${submission.reason ?? 'no reason given'}.` + }: ${reasonClause(submission.reason)}.` ) } // Only `accepted` is an acknowledgement — the same rule the mail lane already applies. A thrown @@ -287,7 +292,7 @@ export async function sendStructuredWorkerPreamble(args: { // `outcome_unknown` receipt whose nextCommands send the coordinator to look. throw new OrchestrationError( 'operation_unknown', - `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${reasonClause(submission.reason)}.` ) } From e9e40b9838401f007745e8564d8ddf1f78c1084b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:21:53 -0700 Subject: [PATCH 069/122] refactor(native-chat): a compaction ends as compacted, failed, or unconfirmed, never an unlabelled error --- .../claude-structured-compaction.test.ts | 4 +- .../claude/claude-structured-compaction.ts | 2 +- ...odex-structured-compaction-refusal.test.ts | 2 +- .../codex/codex-structured-session-adapter.ts | 15 ++-- .../structured-conversation-command.test.ts | 22 +++-- .../structured-conversation-command.ts | 12 +-- .../structured-session-compaction.test.ts | 39 ++++----- .../structured-session-compaction.ts | 85 +++++++++---------- 8 files changed, 86 insertions(+), 95 deletions(-) diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts index 5255ce37ea1..70aabc5ae50 100644 --- a/src/main/claude/claude-structured-compaction.test.ts +++ b/src/main/claude/claude-structured-compaction.test.ts @@ -22,7 +22,7 @@ describe('Claude compaction transcript content', () => { } } expect(isClaudeCompactionContent(tracker, event)).toBe(false) - const completion = tracker.run('orca-session', 'provider', async () => ({})) + const completion = tracker.run('orca-session', 'provider', async () => undefined) expect(isClaudeCompactionContent(tracker, event)).toBe(true) expect(isClaudeCompactionContent(tracker, { ...event, sessionId: 'other' })).toBe(false) expect(isClaudeCompactionContent(tracker, { ...event, message: { type: 'result' } })).toBe( @@ -46,7 +46,7 @@ describe('Claude compaction transcript content', () => { await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toEqual({ error: 'provider_write_failed: input closed' }) + await expect(pending).resolves.toEqual({ outcome: 'failed' }) }) it('keeps waiting when the command write outcome is ambiguous', async () => { diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts index 6e64eebbf13..5b1f67e2e11 100644 --- a/src/main/claude/claude-structured-compaction.ts +++ b/src/main/claude/claude-structured-compaction.ts @@ -21,7 +21,7 @@ export function compactClaudeSession( body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } }) if (result.state === 'rejected') { - return { error: result.reason } + return { outcome: 'failed' as const } } return undefined }, diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts index 17b9e7af20b..437f32f0a8f 100644 --- a/src/main/codex/codex-structured-compaction-refusal.test.ts +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -21,7 +21,7 @@ describe('Codex compaction refused at the request', () => { await expect( adapter.compact({ turnId: 'compact-1', sessionId: 'session-1', fence: 7 }) ).resolves.toEqual({ - error: 'codex app-server thread/compact/start failed: thread has nothing to compact', + outcome: 'failed', detail: { text: 'thread has nothing to compact', audience: 'person' } }) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 8907539cc01..9f5d0f28b3c 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -257,13 +257,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap { threadId: session.threadId }, { timeoutMs: this.deps.requestTimeoutMs } ) - .catch((error) => { - if (isCodexAppServerRequestError(error)) { - const detail = error.providerDiagnostic - return { error: error.message, ...(detail ? { detail } : {}) } + .then( + () => undefined, + (error) => { + if (isCodexAppServerRequestError(error)) { + const detail = error.providerDiagnostic + return { outcome: 'failed' as const, ...(detail ? { detail } : {}) } + } + throw error } - throw error - }) + ) }, input.onLateResult, input.turnId 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 a7b898e429e..b842c1cbd41 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 @@ -7,6 +7,7 @@ import type { AgentSessionConversationCommand } from '../../../shared/agent-sess import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredSessionCompactionResult } from './structured-session-compaction' import { HOST_TEST_NOW, HOST_TEST_SESSION, @@ -43,7 +44,7 @@ function commandParams(command: AgentSessionConversationCommand) { beforeEach(async () => { resetHostTestOperationIds() acquisitions = 0 - compact.mockReset().mockResolvedValue({}) + compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) store = await AgentSessionRecordStore.open({ directory: join(directory, 'store'), @@ -128,7 +129,7 @@ describe('host conversation commands', () => { it('reports provider compaction failure without a stuck lifecycle', async () => { const detail = { text: 'Not enough messages to compact.', audience: 'person' as const } - compact.mockResolvedValue({ error: 'Not enough messages to compact.', detail }) + compact.mockResolvedValue({ outcome: 'failed', detail }) const failure = { kind: 'compactionFailed', detail } expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, @@ -147,8 +148,8 @@ describe('host conversation commands', () => { }) }) - it("keeps Orca's own compaction error out of the sentence", async () => { - compact.mockResolvedValue({ error: 'Compaction did not complete.' }) + it('says only that compaction failed when the provider gave no words', async () => { + compact.mockResolvedValue({ outcome: 'failed' }) expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, value: { error: 'Compaction failed.', failure: { kind: 'compactionFailed' } } @@ -156,10 +157,7 @@ describe('host conversation commands', () => { }) it('records a compaction the provider never confirmed as unconfirmed, not failed', async () => { - compact.mockResolvedValue({ - error: 'The provider exited during compaction.', - unconfirmed: true - }) + compact.mockResolvedValue({ outcome: 'unconfirmed' }) const failure = { kind: 'compactionUnconfirmed' } expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, @@ -290,7 +288,7 @@ describe('host conversation commands', () => { expect(compact).toHaveBeenCalledTimes(1) }) it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { - let finish!: (value: {}) => void + let finish!: (value: StructuredSessionCompactionResult) => void compact.mockImplementation( () => new Promise((resolve) => { @@ -318,7 +316,7 @@ describe('host conversation commands', () => { }) expect(cancel).toMatchObject({ ok: true, value: { cancelled: true } }) expect(adapter.cancelTurn).toHaveBeenCalled() - finish({}) + finish({ outcome: 'compacted' }) await running }) @@ -344,7 +342,7 @@ describe('host conversation commands', () => { compact.mockRejectedValue(new Error('connection lost')) const params = commandParams('compact') await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await compact.mock.calls[0]![0].onLateResult?.({}) + await compact.mock.calls[0]![0].onLateResult?.({ outcome: 'compacted' }) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, replayed: true, @@ -381,7 +379,7 @@ describe('host conversation commands', () => { replayed: true, value: { state: 'unknown' } }) - compact.mockResolvedValue({}) + compact.mockResolvedValue({ outcome: 'compacted' }) expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, value: { state: 'completed' } 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 10bd7198e5e..fbb5520de84 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 @@ -31,12 +31,14 @@ import type { StructuredSessionCompactionResult } from './structured-session-com function compactionFailure( result: StructuredSessionCompactionResult ): AgentSessionFailureFact | undefined { - if (result.error === undefined) { - return undefined + switch (result.outcome) { + case 'compacted': + return undefined + case 'unconfirmed': + return agentSessionFailureFact('compactionUnconfirmed') + case 'failed': + return agentSessionFailureFact('compactionFailed', { detail: result.detail }) } - return result.unconfirmed - ? agentSessionFailureFact('compactionUnconfirmed') - : agentSessionFailureFact('compactionFailed', { detail: result.detail }) } function compactionStatusBody(failure: AgentSessionFailureFact | undefined) { diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts index 2e305391211..8936a4a28d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts @@ -6,7 +6,7 @@ describe('structured compaction lifecycle', () => { const tracker = new StructuredSessionCompaction() const finished = vi.fn() const result = tracker - .run('session', 'thread', async () => ({})) + .run('session', 'thread', async () => undefined) .then((value) => { finished() return value @@ -28,7 +28,7 @@ describe('structured compaction lifecycle', () => { threadId: 'thread', turn: { id: 'compact-turn', status: 'completed' } }) - await expect(result).resolves.toEqual({}) + await expect(result).resolves.toEqual({ outcome: 'compacted' }) }) it('observes notifications arriving before the request acknowledgment', async () => { @@ -42,7 +42,7 @@ describe('structured compaction lifecycle', () => { }) }) ).resolves.toEqual({ - error: 'Unavailable', + outcome: 'failed', detail: { text: 'Unavailable', audience: 'person' } }) }) @@ -67,9 +67,9 @@ describe('structured compaction lifecycle', () => { }) await expect(result).resolves.toEqual( state === 'success' - ? {} + ? { outcome: 'compacted' } : { - error: 'Not enough messages to compact.', + outcome: 'failed', detail: { text: 'Not enough messages to compact.', audience: 'person' } } ) @@ -80,29 +80,26 @@ describe('structured compaction lifecycle', () => { const tracker = new StructuredSessionCompaction() const pending = tracker.run('s', 'p', async () => {}) tracker.ended('s') - await expect(pending).resolves.toEqual({ - error: 'The provider exited during compaction.', - unconfirmed: true - }) + await expect(pending).resolves.toEqual({ outcome: 'unconfirmed' }) const next = tracker.run('s', 'p', async () => { tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) }) - await expect(next).resolves.toEqual({}) + await expect(next).resolves.toEqual({ outcome: 'compacted' }) }) it('reconciles a terminal frame after timeout without repeating the provider request', async () => { vi.useFakeTimers() try { const tracker = new StructuredSessionCompaction(10) const late = vi.fn(async () => {}) - const invoke = vi.fn(async () => ({})) + const invoke = vi.fn(async () => undefined) const result = tracker.run('s', 'p', invoke, late) const rejected = expect(result).rejects.toThrow('unconfirmed') await vi.advanceTimersByTimeAsync(11) await rejected tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - expect(late).toHaveBeenCalledWith({}) + expect(late).toHaveBeenCalledWith({ outcome: 'compacted' }) expect(invoke).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -111,40 +108,34 @@ describe('structured compaction lifecycle', () => { it('does not mistake an unrelated completed turn for compaction', async () => { const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => ({})) + const result = tracker.run('s', 't', async () => undefined) tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'completed' } }) - await expect(result).resolves.toEqual({ - error: 'Compaction was not confirmed by the provider.', - unconfirmed: true - }) + await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) }) it('fails, not merely unconfirmed, a Codex compaction turn that did not complete', async () => { const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => ({})) + const result = tracker.run('s', 't', async () => undefined) tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'interrupted' } }) - await expect(result).resolves.toEqual({ error: 'Compaction did not complete.' }) + await expect(result).resolves.toEqual({ outcome: 'failed' }) }) it('leaves a Claude turn that ended without confirming compaction unconfirmed', async () => { const tracker = new StructuredSessionCompaction() const result = tracker.run('s', 'p', async () => {}) tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - await expect(result).resolves.toEqual({ - error: 'Compaction was not confirmed by the provider.', - unconfirmed: true - }) + await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) }) it('fails a Claude compaction whose turn ended in error', async () => { const tracker = new StructuredSessionCompaction() const result = tracker.run('s', 'p', async () => {}) tracker.claude('s', { type: 'result', subtype: 'error_during_execution', session_id: 'p' }) - await expect(result).resolves.toEqual({ error: 'Compaction did not complete.' }) + await expect(result).resolves.toEqual({ outcome: 'failed' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts index 80fb94a719f..5f5c88d0b07 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -1,23 +1,30 @@ -import { - isProviderDiagnostic, - providerDiagnostic, - type ProviderDiagnostic -} from '../../../shared/agent-session-failure' +import { providerDiagnostic, type ProviderDiagnostic } from '../../../shared/agent-session-failure' -/** `error` is Orca's account of a compaction that did not succeed; `unconfirmed` when the provider - * never said whether it compacted, so it may have; `detail` is the provider's own words, if any. */ -export type StructuredSessionCompactionResult = { - error?: string - unconfirmed?: true - detail?: ProviderDiagnostic +/** How a compaction ended. `unconfirmed`: the provider never said whether it compacted, so it may + * have. `detail` is the provider's own words for a failure, when it gave any. */ +export type StructuredSessionCompactionResult = + | { outcome: 'compacted' } + | { outcome: 'failed'; detail?: ProviderDiagnostic } + | { outcome: 'unconfirmed' } + +/** A compaction that never began: refused before it was sent, or by the provider at the request. */ +export type StructuredSessionCompactionRefusal = Extract< + StructuredSessionCompactionResult, + { outcome: 'failed' } +> + +function compactionFailed( + detail: ProviderDiagnostic | undefined +): StructuredSessionCompactionRefusal { + return { outcome: 'failed', ...(detail ? { detail } : {}) } } type PendingCompaction = { identity: string commandTurnId?: string turnId?: string - error?: string - detail?: ProviderDiagnostic + /** The provider reported the compaction failed, with its words when it gave any. */ + failed?: { detail?: ProviderDiagnostic } compacted: boolean finish: (result: StructuredSessionCompactionResult) => void } @@ -41,7 +48,7 @@ export class StructuredSessionCompaction { async run( sessionId: string, identity: string, - invoke: () => Promise, + invoke: () => Promise, onLateResult?: (result: StructuredSessionCompactionResult) => Promise, commandTurnId?: string ): Promise { @@ -75,12 +82,9 @@ export class StructuredSessionCompaction { // Observe rejection even while invoke is waiting for its own receipt. void completion.catch(() => {}) try { - const admission = record(await invoke()) - if (typeof admission.error === 'string') { - const detail = isProviderDiagnostic(admission.detail) ? admission.detail : undefined - this.pending - .get(sessionId) - ?.finish({ error: admission.error, ...(detail ? { detail } : {}) }) + const refusal = await invoke() + if (refusal) { + this.pending.get(sessionId)?.finish(compactionFailed(refusal.detail)) } return await completion } catch (error) { @@ -107,9 +111,8 @@ export class StructuredSessionCompaction { } ended(sessionId: string): void { - this.pending - .get(sessionId) - ?.finish({ error: 'The provider exited during compaction.', unconfirmed: true }) + // It may have compacted before it exited. + this.pending.get(sessionId)?.finish({ outcome: 'unconfirmed' }) } codex(sessionId: string, method: string, value: unknown): void { @@ -127,16 +130,12 @@ export class StructuredSessionCompaction { } if (method === 'turn/completed' && turn.id === pending.turnId) { const error = record(turn.error).message - const detail = typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined pending.finish( - turn.status === 'completed' - ? pending.compacted - ? {} - : { error: 'Compaction was not confirmed by the provider.', unconfirmed: true } - : { - error: typeof error === 'string' ? error : 'Compaction did not complete.', - ...(detail ? { detail } : {}) - } + turn.status !== 'completed' + ? compactionFailed( + typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined + ) + : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } ) } } @@ -147,12 +146,12 @@ export class StructuredSessionCompaction { return } if (message.compact_result === 'failed') { - pending.error = - typeof message.compact_error === 'string' ? message.compact_error : 'Compaction failed.' - pending.detail = - typeof message.compact_error === 'string' - ? providerDiagnostic(message.compact_error, 'person') - : undefined + pending.failed = { + detail: + typeof message.compact_error === 'string' + ? providerDiagnostic(message.compact_error, 'person') + : undefined + } } if (message.compact_result === 'success' || message.subtype === 'compact_boundary') { pending.compacted = true @@ -162,14 +161,12 @@ export class StructuredSessionCompaction { message.is_error === true || (typeof message.subtype === 'string' && message.subtype.startsWith('error')) ) { - pending.error ??= 'Compaction did not complete.' + pending.failed ??= {} } pending.finish( - pending.error !== undefined - ? { error: pending.error, ...(pending.detail ? { detail: pending.detail } : {}) } - : pending.compacted - ? {} - : { error: 'Compaction was not confirmed by the provider.', unconfirmed: true } + pending.failed + ? compactionFailed(pending.failed.detail) + : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } ) } } From 7243c96f690d4c1693e5b6cc56e154f5f85e4ce3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:30:29 -0700 Subject: [PATCH 070/122] refactor(native-chat): a failure's sentence is written only from its fact A writer could choose its sentence and its kind separately, so five writers put hand-written words beside a fact that said something else. One shared constructor, agentSessionFailureWords(fact, { surface, agentName }), now makes both, and the journal types refuse anything else: a status row, a rejected message or a conversation command that carries a fact must carry the sentence that constructor branded. Persisted rows keep their shape. - The sentence table and the restart table move to src/shared, as the English default a client copy table can reuse. - A rejection's legacy markers come from the same constructor. A write failure is now the bare `provider_write_failed` marker, which released clients already hide; its error goes to the log. - An image Orca refuses carries which check it failed (and the limit) in the fact instead of a sentence; an empty message gets its own kind, and a non-user message is Orca's fault. - The exit row, the interrupted compaction, the /clear failure and the rewind placeholder no longer carry their own words beside a fact: the exit row says what its fact says, and the placeholder carries no fact. --- ...ured-dispatch-attachment-rejection.test.ts | 43 +++- .../claude-structured-dispatch-content.ts | 117 ++++++---- .../claude/claude-structured-dispatch.test.ts | 20 +- src/main/claude/claude-structured-dispatch.ts | 29 +-- ...e-structured-session-adapter-turns.test.ts | 2 +- .../claude/claude-structured-session-state.ts | 2 +- src/main/codex/codex-structured-turn-start.ts | 27 ++- .../journal-crash-boundary.test.ts | 12 +- .../journal-dispatch-reason-bound.test.ts | 5 +- .../journal-pending-submission-recovery.ts | 2 +- .../journal-restart-reconciliation.test.ts | 8 +- .../journal-restart-reconciliation.ts | 7 +- .../journal-store-close.test.ts | 7 +- .../journal-store-contracts.ts | 7 +- .../agent-session-journal/journal-store.ts | 2 +- ...-agent-session-accept-then-deliver.test.ts | 16 +- .../structured-agent-session-adapter.ts | 4 +- ...session-dead-generation-settlement.test.ts | 31 ++- ...gent-session-dead-generation-settlement.ts | 28 +-- .../structured-agent-session-delivery-loop.ts | 10 +- ...uctured-agent-session-failure-text.test.ts | 88 ++----- .../structured-agent-session-failure-text.ts | 134 ++--------- .../structured-agent-session-host-delivery.ts | 4 +- .../structured-agent-session-host-lifetime.ts | 5 +- ...structured-agent-session-host-mutations.ts | 7 +- ...ured-agent-session-late-settlement.test.ts | 9 +- ...gent-session-provider-child-record.test.ts | 11 +- ...red-agent-session-provider-restore.test.ts | 7 +- ...ed-agent-session-restart-ownership.test.ts | 7 +- ...ructured-agent-session-send-preparation.ts | 6 +- .../structured-agent-session-send.test.ts | 9 +- ...red-agent-session-settlement-retry.test.ts | 4 +- ...uctured-agent-session-start-failure-row.ts | 13 +- ...red-agent-session-surface-lifetime.test.ts | 11 +- .../structured-agent-session-turns-prompt.ts | 10 +- .../structured-agent-session-turns.ts | 40 ++-- ...ured-agent-session-unexpected-exit.test.ts | 4 +- ...tructured-agent-session-unexpected-exit.ts | 8 +- .../structured-compaction-recovery.test.ts | 48 ++++ .../structured-compaction-recovery.ts | 12 +- .../structured-conversation-command.test.ts | 11 +- .../structured-conversation-command.ts | 37 +-- .../structured-rewind-journal-body.test.ts | 6 +- .../structured-rewind-journal-body.ts | 5 +- .../unhandled-provider-frame.ts | 4 +- ...stration-structured-worker-session.test.ts | 4 +- .../structured-agent-session-hold.test.ts | 7 +- .../agent-session-conversation-command.ts | 14 +- .../agent-session-failure-words.test.ts | 151 ++++++++++++ src/shared/agent-session-failure-words.ts | 221 ++++++++++++++++++ src/shared/agent-session-failure.ts | 49 +++- src/shared/agent-session-journal-types.ts | 20 +- ...d-agent-session-dispatch-rejection.test.ts | 29 ++- ...ctured-agent-session-dispatch-rejection.ts | 58 +---- ...tructured-agent-session-projection.test.ts | 18 +- ...red-agent-session-send-disposition.test.ts | 3 +- 56 files changed, 946 insertions(+), 507 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts create mode 100644 src/shared/agent-session-failure-words.test.ts create mode 100644 src/shared/agent-session-failure-words.ts diff --git a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts index f0502efc6c7..a23767c0968 100644 --- a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts +++ b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts @@ -22,7 +22,7 @@ describe('Claude structured dispatch attachment rejections', () => { ).resolves.toEqual({ state: 'rejected', reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', - rejection: { kind: 'attachmentInvalid' } + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } } }) expect(session.connection.send).not.toHaveBeenCalled() }) @@ -46,7 +46,10 @@ describe('Claude structured dispatch attachment rejections', () => { state: 'rejected', reason: 'The images on this message add up to more than 20 MB, so the message was not sent.', - rejection: { kind: 'attachmentInvalid' } + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'totalTooLarge', limit: 20 * 1024 * 1024 } + } }) expect(session.connection.send).not.toHaveBeenCalled() } finally { @@ -66,9 +69,11 @@ describe('Claude structured dispatch attachment rejections', () => { dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) ).resolves.toEqual({ state: 'rejected', - reason: - 'An image on this message is empty or larger than 5 MB, so the message was not sent.', - rejection: { kind: 'attachmentInvalid' } + reason: 'An image on this message is larger than 5 MB, so the message was not sent.', + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'tooLarge', limit: 5 * 1024 * 1024 } + } }) expect(session.connection.send).not.toHaveBeenCalled() } finally { @@ -76,6 +81,28 @@ describe('Claude structured dispatch attachment rejections', () => { } }) + it('names an empty image as empty, not as too large', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'empty.png') + await writeFile(path, Buffer.alloc(0)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'An image on this message is empty, so the message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + it('names an unsupported image type in words a person can act on', async () => { const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) try { @@ -92,7 +119,7 @@ describe('Claude structured dispatch attachment rejections', () => { state: 'rejected', reason: 'Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.', - rejection: { kind: 'attachmentInvalid' } + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'unsupportedType' } } }) } finally { await rm(directory, { recursive: true, force: true }) @@ -109,7 +136,7 @@ describe('Claude structured dispatch attachment rejections', () => { ).resolves.toEqual({ state: 'rejected', reason: 'This message is empty, so it was not sent.', - rejection: { kind: 'hostFault' } + rejection: { kind: 'emptyMessage' } }) await expect( dispatchClaudeTurn(session, { @@ -118,7 +145,7 @@ describe('Claude structured dispatch attachment rejections', () => { }) ).resolves.toEqual({ state: 'rejected', - reason: "This message can't be sent to the agent.", + reason: "Orca ran into a problem, so this didn't go through. Try again.", rejection: { kind: 'hostFault' } }) expect(session.connection.send).not.toHaveBeenCalled() diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 1b84976e94f..1b97849a0e9 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,39 +3,57 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' -import type { AgentJournalDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' -import { agentSessionFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type AgentSessionFailureFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import { claudeRecord } from './claude-structured-item-translation' /** Orca refused the message's content, as opposed to failing to read an attachment. */ export class ClaudeDispatchContentError extends Error { - /** What the person reads on the rejected message; `message` stays for logs. */ - readonly sentence: string - /** `hostFault`: a body no Orca client sends, so the fault is Orca's, not an attachment's. */ - readonly kind: 'attachmentInvalid' | 'hostFault' + /** What was wrong, typed where the check saw it; `message` stays for logs. */ + readonly failure: AgentSessionFailureFact - constructor( - message: string, - sentence: string, - kind: 'attachmentInvalid' | 'hostFault' = 'attachmentInvalid' - ) { + constructor(message: string, failure: AgentSessionFailureFact) { super(message) this.name = 'ClaudeDispatchContentError' - this.sentence = sentence - this.kind = kind + this.failure = failure } } -/** Why a message whose content could not be built was not sent: Orca's own refusal of it, in the - * words that refusal carries, or an attachment it could not read. Never the provider. */ +function attachmentError(message: string, attachment: AgentSessionAttachmentProblem): Error { + return new ClaudeDispatchContentError( + message, + agentSessionFailureFact('attachmentInvalid', { attachment }) + ) +} + +/** A message Claude rejected, in the words that name Claude and its legacy markers. */ +export function claudeDispatchRejection( + failure: AgentSessionFailureFact +): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.claude, + provider: 'claude' + }) +} + +/** Why a message whose content could not be built was not sent: Orca's own refusal of it, or an + * attachment it could not read. Never the provider. */ export function claudeDispatchContentRejection(error: unknown): AgentJournalDispatchRejection { if (error instanceof ClaudeDispatchContentError) { - return { reason: error.sentence, rejection: agentSessionFailureFact(error.kind) } + return claudeDispatchRejection(error.failure) } // The row says only that it could not be read; why belongs in the log. console.warn('[claude-dispatch] attachment could not be read:', error) - return agentSessionFailureRejection(agentSessionFailureFact('attachmentUnreadable')) + return claudeDispatchRejection(agentSessionFailureFact('attachmentUnreadable')) } const BYTES_PER_MB = 1024 * 1024 @@ -52,20 +70,17 @@ type ImageBudget = { export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise { const file = await openImpl(path, 'r') try { - const invalidImage = (): Error => - new ClaudeDispatchContentError( - `Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`, - `An image on this message is empty or larger than ${MAX_IMAGE_BYTES / BYTES_PER_MB} MB, so the message was not sent.` - ) + const tooLarge = (): Error => + attachmentError(`Claude image must be no larger than ${MAX_IMAGE_BYTES} bytes`, { + reason: 'tooLarge', + limit: MAX_IMAGE_BYTES + }) const info = await file.stat() if (!info.isFile()) { - throw new ClaudeDispatchContentError( - 'Claude image must be a file', - "An image on this message isn't a file, so the message was not sent." - ) + throw attachmentError('Claude image must be a file', { reason: 'notAFile' }) } if (info.size > MAX_IMAGE_BYTES) { - throw invalidImage() + throw tooLarge() } const buffer = Buffer.allocUnsafe(info.size + 1) let bytesRead = 0 @@ -79,8 +94,15 @@ export async function readClaudeImage(path: string, openImpl: typeof open = open // A file can grow after the initial stat and after the final read returns // zero. Prove the descriptor's size matches what was copied before sending. const finalInfo = await file.stat() - if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) { - throw invalidImage() + if (bytesRead > MAX_IMAGE_BYTES) { + throw tooLarge() + } + if (finalInfo.size !== bytesRead) { + // Not the image's fault: it changed while Orca read it, so it reads as unreadable. + throw new Error('Claude image changed while it was read') + } + if (bytesRead === 0) { + throw attachmentError('Claude image must be a non-empty file', { reason: 'empty' }) } return buffer.subarray(0, bytesRead) } finally { @@ -102,34 +124,30 @@ async function imageContent( ): Promise { budget.count += 1 if (budget.count > MAX_IMAGE_COUNT) { - throw new ClaudeDispatchContentError( - `Claude messages support at most ${MAX_IMAGE_COUNT} images`, - `Claude accepts at most ${MAX_IMAGE_COUNT} images in one message, so this message was not sent.` - ) + throw attachmentError(`Claude messages support at most ${MAX_IMAGE_COUNT} images`, { + reason: 'tooMany', + limit: MAX_IMAGE_COUNT + }) } if (block.url) { return { type: 'image', source: { type: 'url', url: block.url } } } if (!block.path) { - throw new ClaudeDispatchContentError( - 'image reference has neither a path nor a URL', - 'An image on this message has no file to send, so the message was not sent.' - ) + throw attachmentError('image reference has neither a path nor a URL', { reason: 'noSource' }) } const data = await readClaudeImage(block.path) budget.localBytes += data.byteLength if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { - throw new ClaudeDispatchContentError( - `Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`, - `The images on this message add up to more than ${MAX_TOTAL_IMAGE_BYTES / BYTES_PER_MB} MB, so the message was not sent.` - ) + throw attachmentError(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`, { + reason: 'totalTooLarge', + limit: MAX_TOTAL_IMAGE_BYTES + }) } const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] if (!mediaType) { - throw new ClaudeDispatchContentError( - `Claude does not support the image type ${extname(block.path)}`, - 'Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.' - ) + throw attachmentError(`Claude does not support the image type ${extname(block.path)}`, { + reason: 'unsupportedType' + }) } return { type: 'image', @@ -151,10 +169,10 @@ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { + // No Orca client sends one, so the fault is Orca's. throw new ClaudeDispatchContentError( 'Claude dispatch accepts only user messages', - "This message can't be sent to the agent.", - 'hostFault' + agentSessionFailureFact('hostFault') ) } const images: unknown[] = [] @@ -173,8 +191,7 @@ export async function claudeDispatchMessageContent( if (content.length === 0) { throw new ClaudeDispatchContentError( 'Claude dispatch requires text or an image', - 'This message is empty, so it was not sent.', - 'hostFault' + agentSessionFailureFact('emptyMessage') ) } return content diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index b299c94a5a8..c540167de4c 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { dispatchClaudeTurn, resolveClaudeReplayTurn } from './claude-structured-dispatch' -import { readClaudeImage } from './claude-structured-dispatch-content' +import { ClaudeDispatchContentError, readClaudeImage } from './claude-structured-dispatch-content' import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' import type { ClaudeSession } from './claude-structured-session-state' import { @@ -381,7 +381,7 @@ describe('Claude structured dispatch image limits', () => { }) ).resolves.toEqual({ state: 'rejected', - reason: 'provider_write_failed: broken pipe', + reason: 'provider_write_failed', rejection: { kind: 'writeFailed' } }) expect(session.dispatchWaiters).toEqual([firstWaiter]) @@ -396,6 +396,7 @@ describe('Claude structured dispatch image limits', () => { }) it('does not let a provably unwritten attempt block retry correlation', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const send = vi .fn() .mockRejectedValueOnce(claudeUnwrittenUserMessageError(new Error('broken pipe'))) @@ -407,9 +408,15 @@ describe('Claude structured dispatch image limits', () => { dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) ).resolves.toEqual({ state: 'rejected', - reason: 'provider_write_failed: broken pipe', + reason: 'provider_write_failed', rejection: { kind: 'writeFailed' } }) + // The row keeps only the marker; why the write failed goes to the log. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] message could not be handed to Claude:', + expect.objectContaining({ message: expect.stringContaining('broken pipe') }) + ) + warn.mockRestore() expect(session.dispatchWaiters).toHaveLength(0) expect(session.retiredDispatchWaiters).toHaveLength(0) @@ -769,8 +776,11 @@ describe('Claude structured dispatch image limits', () => { read, close: vi.fn().mockResolvedValue(undefined) } as never) - await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow( - `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + // It changed while Orca read it: unreadable, never a limit the image did not break. + const rejected = await readClaudeImage('/controlled/growing.png', open).catch( + (error: unknown) => error ) + expect(rejected).not.toBeInstanceOf(ClaudeDispatchContentError) + expect(rejected).toMatchObject({ message: 'Claude image changed while it was read' }) }) }) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index b5000a8a27c..220542fab91 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -21,16 +21,13 @@ import { claudeDispatchContentKey, claudeDispatchContentRejection, claudeDispatchInvokesSlashCommand, - claudeDispatchMessageContent + claudeDispatchMessageContent, + claudeDispatchRejection } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' -import { - DISPATCH_REJECTED_QUEUE_FULL, - DISPATCH_REJECTION_CANCELLED, - dispatchQueueFullRejection, - dispatchWriteFailureRejection -} from '../../shared/structured-agent-session-dispatch-rejection' -import { agentSessionFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, claudeUserMessageWasProvablyUnwritten @@ -217,7 +214,7 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - ...DISPATCH_REJECTION_CANCELLED + ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) }) } } @@ -234,6 +231,12 @@ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { } } +/** The row keeps only the marker released clients hide; why the write failed belongs in the log. */ +function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchRejection { + console.warn('[claude-dispatch] message could not be handed to Claude:', error) + return claudeDispatchRejection(agentSessionFailureFact('writeFailed')) +} + export async function dispatchClaudeTurn( session: ClaudeSession, input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, @@ -246,11 +249,11 @@ export async function dispatchClaudeTurn( return { state: 'rejected', ...claudeDispatchContentRejection(error) } } if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { - return { state: 'rejected', ...dispatchQueueFullRejection(DISPATCH_REJECTED_QUEUE_FULL) } + return { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact('queueFull')) } } const startupFailure = claudeStartupFailureFact(session) if (startupFailure) { - return { state: 'rejected', ...agentSessionFailureRejection(startupFailure) } + return { state: 'rejected', ...claudeDispatchRejection(startupFailure) } } // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. @@ -296,7 +299,7 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } - return { state: 'rejected', ...dispatchWriteFailureRejection(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } const waiter = replay.waiter if (waiter.settledUuid) { @@ -314,7 +317,7 @@ export async function dispatchClaudeTurn( waiter.resolve(null) // The frame was never handed to the SDK's input pump, so this is not doubt: // the message provably did not happen, which is what `rejected` means. - return { state: 'rejected', ...dispatchWriteFailureRejection(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } if (!waiter.retired) { retireWaiter(session, waiter) diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 2479cd99cb1..c77d5ee0a91 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -70,7 +70,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { }) ).resolves.toEqual({ state: 'rejected', - reason: 'provider_write_failed: broken pipe', + reason: 'provider_write_failed', rejection: { kind: 'writeFailed' } }) }) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1f64f295586..203714ae7d5 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,4 +1,4 @@ -import type { AgentJournalDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { AgentSessionFailureFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity, diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index dd0aa544caf..42422f7fee3 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,5 +1,10 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import { agentSessionFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { AgentSessionFailureFact } from '../../shared/agent-session-failure' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -9,10 +14,6 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { - DISPATCH_REJECTED_CODEX_QUEUE_FULL, - dispatchQueueFullRejection -} from '../../shared/structured-agent-session-dispatch-rejection' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' // Writing a Codex turn and learning which message landed where, which are not @@ -120,6 +121,15 @@ export async function startCodexTurn( return true } +/** A message Codex rejected, in the words that name Codex and its legacy markers. */ +function codexDispatchRejection(failure: AgentSessionFailureFact): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.codex, + provider: 'codex' + }) +} + /** * One submission's outcome as the wire must read it: admitted means Codex owns * the message and its identity settles on the echo, rejected is Codex answering @@ -133,10 +143,7 @@ export async function dispatchCodexTurn( ): Promise { try { if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { - return { - state: 'rejected', - ...dispatchQueueFullRejection(DISPATCH_REJECTED_CODEX_QUEUE_FULL) - } + return { state: 'rejected', ...codexDispatchRejection(agentSessionFailureFact('queueFull')) } } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { @@ -145,7 +152,7 @@ export async function dispatchCodexTurn( // Codex's own words, when it gave any, are the one part of the error a person can use. return { state: 'rejected', - ...agentSessionFailureRejection( + ...codexDispatchRejection( agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index b2df8368512..6cc267a9054 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -16,7 +16,8 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { dispatchWriteFailureRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, @@ -180,7 +181,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_write_failed', state: 'rejected', - ...dispatchWriteFailureRejection(new Error('broken pipe')), + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }), fence: 1 }) @@ -191,7 +192,7 @@ describe('crash between provider accept and journal commit', () => { // so recovery must not reopen it as doubt. expect(restarted.submissions()[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed' }) expect(restarted.submissions()[0]?.recovered).toBeUndefined() expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) @@ -250,8 +251,9 @@ describe('crash between provider accept and journal commit', () => { await restarted.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', - reason: 'not_delivered', - rejection: { kind: 'notDelivered' }, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: 2, recovered: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index d7708c45f42..668232e607d 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -47,9 +47,10 @@ async function settle(reason: string): Promise { }) await journal.resolveDispatch({ clientMessageId: 'msg-1', - state: 'rejected', + // A rejection's reason comes only from `agentSessionFailureWords`; an unknown's is still free + // text (an adapter's error), so it is what reaches the bound. + state: 'unknown', reason, - rejection: { kind: 'providerRejected' }, fence: 1 }) return journal.snapshot().submissions[0]?.reason ?? null 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 e72d579871c..c252a57d8a5 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,4 +1,4 @@ -import type { AgentJournalDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' 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' diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 80ff17e5b8f..b484ec384ed 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -1,7 +1,6 @@ // Wiring the restart reconciler: what provider history is allowed to decide // about a submission the crash boundary could only doubt. -import { DISPATCH_REJECTION_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,6 +15,8 @@ import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -124,7 +125,10 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const submission = journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') // A sentence, since released clients print the reason as it is, and the fact beside it. - expect(submission?.reason).toBe(DISPATCH_REJECTION_NOT_DELIVERED.reason) + expect(submission?.reason).toBe( + agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) + .reason + ) expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) }) 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 769b562a053..7b36fc88dc6 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 @@ -10,7 +10,8 @@ // through the user's Retry, which rotates the client message id; Orca still // never puts a message back on the wire on the user's behalf. -import { DISPATCH_REJECTION_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -117,7 +118,9 @@ export async function reconcileJournalSubmissionsAgainstHistory(input: { : { clientMessageId: outcome.clientMessageId, state: 'rejected', - ...DISPATCH_REJECTION_NOT_DELIVERED, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: input.fence, recovered: true } diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts index bc68652fc46..af53cd16d3d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts @@ -13,6 +13,8 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { journalDatabaseFile } from './journal-paths' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-store-test-open' @@ -121,8 +123,9 @@ describe('closed-state admission happens at enqueue', () => { journal.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', - reason: 'Not sent.', - rejection: { kind: 'hostFault' }, + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { + surface: 'rejection' + }), fence: 1 }) ), 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 115398654b8..c2210efb253 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 @@ -1,4 +1,4 @@ -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalCursor, AgentJournalItemBody, @@ -32,8 +32,9 @@ export type ResolveDispatchInput = { } & ( | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } | { state: 'pending' } - /** `reason` is the sentence released clients print; `rejection` is what newer ones read. */ - | { state: 'rejected'; reason: string; rejection: AgentSessionFailureFact } + /** `reason` is what released clients print, `rejection` what newer ones read: both from + * `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) | { state: 'unknown'; reason?: string | null } ) 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 4b2e1c01cb6..45c42b45f8a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,6 +1,6 @@ // Append-only journal store for one agent session. -import type { AgentJournalDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, 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 dbce62254c4..1df4d867489 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 @@ -10,11 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite 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_REJECTION_HOST_RESTARTED, - DISPATCH_REJECTION_PROVIDER_CLOSED -} from '../../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -36,6 +32,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -462,7 +460,9 @@ describe('what an earlier host process left behind', () => { expect(submission('queued')).toMatchObject({ dispatchState: 'rejected', - ...DISPATCH_REJECTION_HOST_RESTARTED + ...agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) }) expect(dispatch).not.toHaveBeenCalled() }) @@ -599,7 +599,7 @@ describe('an eviction between acceptance and handover', () => { expect(await reopened(id)).toMatchObject({ dispatchState: 'rejected', - ...DISPATCH_REJECTION_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) expect(dispatch).not.toHaveBeenCalled() }) @@ -623,7 +623,7 @@ describe('an eviction between acceptance and handover', () => { expect(await reopened(queued)).toMatchObject({ dispatchState: 'rejected', - ...DISPATCH_REJECTION_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) expect(submission(handed)).toMatchObject({ dispatchState: 'unknown' }) expect(dispatch).toHaveBeenCalledTimes(1) 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 639a5420cdf..0928bc28a2a 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 @@ -35,6 +35,7 @@ import { type AgentSessionRefusalCause } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -132,7 +133,8 @@ export type AgentSessionDispatchOutcome = * anything and never promotes this to `unknown`. */ | { state: 'admitted' } - | { state: 'rejected'; reason: string; rejection: AgentSessionFailureFact } + /** Words from `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } 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 fc5f72ca0c9..dd5a4539616 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 @@ -13,10 +13,12 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import { captureUnfinishedStructuredAgentSessionWork, settleStructuredAgentSessionDeadGeneration, - UNEXPECTED_PROVIDER_EXIT_OUTCOME, unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'The provider stopped while this response was in progress. You can continue in this conversation.' + const SESSION = 'session-dead-generation' const THREAD = 'thread-1' let root: string @@ -174,6 +176,33 @@ describe('dead structured-session generation settlement', () => { expect(statuses[0]?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) }) + it("words Orca's own fault as Orca's, never as the provider stopping", async () => { + await seedUnfinishedWork() + + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_exited_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: true, + // Orca stopped the provider because its own journal failed. + exitFailure: agentSessionFailureFact('hostFault') + }) + + const statuses = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statuses).toEqual([ + { + kind: 'status', + text: "Orca ran into a problem, so this didn't go through. Try again.", + failure: { kind: 'hostFault' } + } + ]) + }) + it('retries an already settled expected close without writing through a closed journal gate', async () => { const settledItem: AgentJournalRenderItem = { itemId: 'codex:thread-1:turn-1:0', 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 2106734f5af..74854b698b9 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 @@ -14,18 +14,16 @@ import type { JournalLifecycleMutationInput } from '../agent-session-journal/jou import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { - structuredAgentSessionStartFailure, - type AgentSessionFailureTextContext -} from './structured-agent-session-failure-text' + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' import { runningTurnLifecycleRevisions, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' -export const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'The provider stopped while this response was in progress. You can continue in this conversation.' - /** Bounds the exit reason a settlement retry keeps as the lease's log evidence; a provider * diagnostic is held to the same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS @@ -100,10 +98,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string showUnexpectedExitOutcome?: boolean - /** Why the provider stopped, as the adapter told it: recorded beside the row, never in its text. */ + /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ exitFailure?: AgentSessionFailureFact /** Who a failed start's sentence names. */ - failureTextContext?: AgentSessionFailureTextContext + failureTextContext?: AgentSessionFailureWordsContext /** 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 } @@ -123,10 +121,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, { - reason: startupFailure.text, - rejection: startupFailure.failure - }) + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] @@ -144,8 +139,13 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { identity: { provider: 'orca', clientMessageId: input.settlementId }, body: { kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: input.exitFailure ?? agentSessionFailureFact('providerExited') + ...agentSessionFailureWords( + input.exitFailure ?? agentSessionFailureFact('providerExited'), + { + ...input.failureTextContext, + surface: 'row' + } + ) } }) } 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 cddc9abe667..ddcbf86189f 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 @@ -14,11 +14,13 @@ import { type AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTION_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionStartFailure, - type AgentSessionFailureTextContext, type StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' @@ -44,7 +46,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number /** Who the chat's failure sentences name. */ - failureTextContext: (sessionId: string) => AgentSessionFailureTextContext + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void } @@ -131,7 +133,7 @@ export class StructuredAgentSessionDeliveryLoop { } await session.journal.rejectQueuedSubmissions( this.deps.conversationFence(sessionId), - DISPATCH_REJECTION_HOST_RESTARTED, + agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { surface: 'rejection' }), // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index 56edff23a84..76d98524eb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - AGENT_SESSION_FAILURE_KINDS, MAX_PROVIDER_DIAGNOSTIC_CHARS, providerDiagnostic, withProviderDiagnostic @@ -8,71 +7,31 @@ import { import { AgentSessionRefusalError, refuse } from '../../../shared/agent-session-wire-refusals' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' -import { - agentSessionFailureText, - structuredAgentSessionStartFailure -} from './structured-agent-session-failure-text' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' /** What Orca's own error text looks like: a code, a marker, a uuid, a path, an exception. */ const ORCA_INTERNAL = /agent_session_|execution_owner|provider_[a-z_]+|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ -describe('the sentence the host persists', () => { - it.each(AGENT_SESSION_FAILURE_KINDS)('for %s is written for a person', (kind) => { - const text = agentSessionFailureText( - { - kind, - // A log detail and a refusal ride with the fact; neither may reach the sentence. - detail: { - text: 'Error: ENOENT /Users/me/.claude/x agent_session_conflict', - audience: 'log' - }, - refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } - }, - { agentName: 'Claude' } - ) - expect(text).not.toMatch(ORCA_INTERNAL) - expect(text).toMatch(/[.]$/) - }) - - it('quotes a provider sentence written for a person, bounded, and never a log', () => { - expect( - agentSessionFailureText({ - kind: 'providerRejected', - detail: { text: 'thread not found.', audience: 'person' } - }) - ).toBe('The provider did not accept this message: thread not found.') - expect( - agentSessionFailureText({ - kind: 'providerRejected', - detail: { text: 'panic at main.rs:4', audience: 'log' } - }) - ).toBe('The provider did not accept this message.') - }) - - it('offers a new chat only when nothing here can restart this one', () => { - const text = (code: 'agent_session_identity_required' | 'agent_session_conflict') => - agentSessionFailureText({ kind: 'restartFailed', refusal: { code } }, { agentName: 'Codex' }) - expect(text('agent_session_identity_required')).toBe( - "Codex couldn't restart. Start a new chat to continue." - ) - expect(text('agent_session_conflict')).toBe("Codex couldn't restart.") - }) -}) - describe('structuredAgentSessionStartFailure', () => { it('keeps a provider diagnostic only when the error carried one', () => { const carried = withProviderDiagnostic( new Error('claude stream-json exited (code 1): boom'), providerDiagnostic('code 1\nboom', 'log') ) - expect(structuredAgentSessionStartFailure({ error: carried })).toEqual({ - text: 'The provider stopped before it finished starting.', - failure: { kind: 'providerStartFailed', detail: { text: 'code 1\nboom', audience: 'log' } } - }) + expect(structuredAgentSessionStartFailure({ error: carried }, { agentName: 'Claude' })).toEqual( + { + reason: 'The provider stopped before it finished starting.', + rejection: { + kind: 'providerStartFailed', + detail: { text: 'code 1\nboom', audience: 'log' } + } + } + ) // Orca's own words, however provider-like, are never promoted to a detail. expect( - structuredAgentSessionStartFailure({ error: new Error('Not logged in. Run /login.') }).failure + structuredAgentSessionStartFailure({ error: new Error('Not logged in. Run /login.') }) + .rejection ).toEqual({ kind: 'providerStartFailed' }) }) @@ -84,8 +43,9 @@ describe('structuredAgentSessionStartFailure', () => { ) expect(structuredAgentSessionStartFailure({ error: refusal }, { agentName: 'Claude' })).toEqual( { - text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', - failure: { kind: 'notSignedIn' } + reason: + 'Claude is not signed in for the selected account. Sign in, then send your message again.', + rejection: { kind: 'notSignedIn' } } ) }) @@ -102,8 +62,8 @@ describe('structuredAgentSessionStartFailure', () => { { agentName: 'Claude' } ) expect(words).toEqual({ - text: "Claude couldn't restart.", - failure: { + reason: "Claude couldn't restart.", + rejection: { kind: 'restartFailed', refusal: { code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } } @@ -114,12 +74,14 @@ describe('structuredAgentSessionStartFailure', () => { expect( structuredAgentSessionStartFailure({ exit: { kind: 'providerExited', detail: { text: 'stderr', audience: 'log' } } - }).failure + }).rejection ).toEqual({ kind: 'providerStartFailed', detail: { text: 'stderr', audience: 'log' } }) - expect(structuredAgentSessionStartFailure({ exit: { kind: 'hostFault' } }).failure).toEqual({ + expect(structuredAgentSessionStartFailure({ exit: { kind: 'hostFault' } }).rejection).toEqual({ kind: 'hostFault' }) - expect(structuredAgentSessionStartFailure({ hostFault: true }).text).not.toMatch(ORCA_INTERNAL) + expect(structuredAgentSessionStartFailure({ hostFault: true }).reason).not.toMatch( + ORCA_INTERNAL + ) }) it("holds any provider detail to the lease record's cap", () => { @@ -130,11 +92,11 @@ describe('structuredAgentSessionStartFailure', () => { const words = structuredAgentSessionStartFailure({ diagnostic: { text: long, audience: 'log' } }) - expect(words.failure.detail?.text).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + expect(words.rejection.detail?.text).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) expect( structuredAgentSessionStartFailure({ exit: { kind: 'providerExited', detail: { text: long, audience: 'log' } } - }).failure.detail?.text + }).rejection.detail?.text ).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) }) @@ -143,7 +105,7 @@ describe('structuredAgentSessionStartFailure', () => { refuse('agent_session_conflict', 'claimConflicted', 'Another process claims this session.') ) expect(thrown.message).toBe('agent_session_conflict') - expect(structuredAgentSessionStartFailure({ refusal: thrown.refusal }).failure).toEqual({ + expect(structuredAgentSessionStartFailure({ refusal: thrown.refusal }).rejection).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 9c0d846e1a8..f210604f511 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -1,126 +1,23 @@ -// The sentence the host persists beside each failure fact, and the facts a start or an exit -// reduces to. -// -// A row's `text` and a rejected message's `reason` are what every released client prints as they -// are, so they are written here for a person and never carry Orca's own error text: not a -// refusal's message, not an exception, not a probe's evidence. A provider's words reach the text -// only when the provider wrote them for a person; a log's never do. Newer clients choose their -// own copy from the fact. +// The facts a start or an exit reduces to, and the one place a failed start is worded. The +// sentence itself comes from `agentSessionFailureWords`, beside the fact it states. import { agentSessionFailureFact, providerDiagnosticOf, type AgentSessionFailureFact, - type AgentSessionFailureKind, type ProviderDiagnostic } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import { agentSessionRefusalReference, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' -import { - DISPATCH_REJECTION_HOST_RESTARTED, - DISPATCH_REJECTION_NOT_DELIVERED, - DISPATCH_REJECTION_PROVIDER_CLOSED, - type AgentJournalDispatchRejection -} from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' -/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ -const MAX_QUOTED_DETAIL_CHARS = 512 - -/** - * Whether a refused start leaves the chat anything to start again from. `false`: 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 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 -} - -export type AgentSessionFailureTextContext = { - /** The chat's agent, when the writer knows it. */ - agentName?: string -} - -type Sentence = (context: AgentSessionFailureTextContext, fact: AgentSessionFailureFact) => string - -function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefined): string { - const quoted = - detail?.audience === 'person' - ? detail.text - .slice(0, MAX_QUOTED_DETAIL_CHARS) - .trim() - .replace(/[.\s]+$/, '') - : '' - return quoted ? `${lead}: ${quoted}.` : `${lead}.` -} - -const FAILURE_SENTENCES = { - providerStartFailed: () => 'The provider stopped before it finished starting.', - notSignedIn: ({ agentName }) => - `${agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then send your message again.`, - historyTooLarge: () => - "This conversation's history is too large to restore here. Start a new chat to continue.", - providerExited: () => 'The provider stopped before this message was sent.', - restartFailed: ({ agentName }, fact) => { - const failed = `${agentName ?? 'The agent'} couldn't restart.` - const code = fact.refusal?.code - return code && !START_REFUSAL_RESUMABLE[code] - ? `${failed} Start a new chat to continue.` - : failed - }, - providerRejected: (_, fact) => - quotingPersonDetail('The provider did not accept this message', fact.detail), - attachmentInvalid: () => "An attachment on this message can't be sent to the agent.", - attachmentUnreadable: () => - "An attachment on this message couldn't be read, so the message was not sent.", - queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.', - writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", - cancelled: () => 'This message was withdrawn before the agent started it.', - chatClosed: () => DISPATCH_REJECTION_PROVIDER_CLOSED.reason, - hostRestarted: () => DISPATCH_REJECTION_HOST_RESTARTED.reason, - notDelivered: () => DISPATCH_REJECTION_NOT_DELIVERED.reason, - compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), - compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', - cancelUnconfirmed: () => 'Cancellation was not confirmed.', - answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', - hostFault: () => "Orca ran into a problem, so this didn't go through. Try again." -} satisfies Record - -/** The sentence a row or a rejected message records for this fact. */ -export function agentSessionFailureText( - fact: AgentSessionFailureFact, - context: AgentSessionFailureTextContext = {} -): string { - const sentence: Sentence = FAILURE_SENTENCES[fact.kind] - return sentence(context, fact) -} - -/** A rejection whose reason is the fact's sentence. The legacy markers are written by their own - * constants, never through here. */ -export function agentSessionFailureRejection( - fact: AgentSessionFailureFact, - context: AgentSessionFailureTextContext = {} -): AgentJournalDispatchRejection { - return { reason: agentSessionFailureText(fact, context), rejection: fact } -} - /** A start that did not land. A refusal the adapter typed keeps its situation; anything else is a * start that failed, with the provider's diagnostic when the error carried one. */ export function providerStartupFailureFact(cause?: unknown): AgentSessionFailureFact { @@ -166,17 +63,17 @@ export type StructuredAgentSessionStartFailureCause = | { error: unknown } /** The child ended before it proved its start, as its ended event told it. */ | { exit: AgentSessionFailureFact | undefined } - /** Only the provider's words are known; the caller names their audience. */ + /** The provider exited while starting; only its words are known, and the caller names their + * audience. */ | { diagnostic: ProviderDiagnostic | undefined } /** Orca's own fault; its error belongs in the log. */ | { hostFault: true } /** Already typed where it was observed. */ | { failure: AgentSessionFailureFact } -export type StructuredAgentSessionStartFailureWords = { - text: string - failure: AgentSessionFailureFact -} +/** A start failure's row repeats the sentence its rejected messages carry: both are about the + * messages the start was for. */ +export type StructuredAgentSessionStartFailureWords = AgentJournalDispatchRejection function startFailureFact(cause: StructuredAgentSessionStartFailureCause): AgentSessionFailureFact { if ('refusal' in cause) { @@ -201,8 +98,7 @@ function startFailureFact(cause: StructuredAgentSessionStartFailureCause): Agent * sentence and this fact, whichever writer saw the start fail. */ export function structuredAgentSessionStartFailure( cause: StructuredAgentSessionStartFailureCause, - context: AgentSessionFailureTextContext = {} + context: AgentSessionFailureWordsContext = {} ): StructuredAgentSessionStartFailureWords { - const failure = startFailureFact(cause) - return { text: agentSessionFailureText(failure, context), failure } + return agentSessionFailureWords(startFailureFact(cause), { ...context, surface: 'rejection' }) } 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 9a5df02b659..1c61c2da61f 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 @@ -16,7 +16,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionFailureTextContext } from './structured-agent-session-send-preparation' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' @@ -50,7 +50,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), failureTextContext: (sessionId) => - structuredAgentSessionFailureTextContext(deps.store.getRecord(sessionId)), + structuredAgentSessionFailureWordsContext(deps.store.getRecord(sessionId)), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) const adoptOpened = async ( 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 a5d05dc532a..4f1429038ed 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 @@ -8,7 +8,8 @@ import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { DISPATCH_REJECTION_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, @@ -63,7 +64,7 @@ export async function abandonQueuedStructuredAgentSessionMessages( await journal .rejectQueuedSubmissions( structuredAgentSessionConversationFence(deps.store, sessionId), - DISPATCH_REJECTION_PROVIDER_CLOSED + agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) ) .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, 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 cdbc70abc2e..66b70915b09 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 @@ -22,9 +22,10 @@ import type { AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' import { - DISPATCH_REJECTION_CANCELLED, + agentSessionFailureWords, type AgentJournalDispatchRejection -} from '../../../shared/structured-agent-session-dispatch-rejection' +} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -157,7 +158,7 @@ export function cancelStructuredAgentSessionTurn( // Stop withdraws every queued message first, whatever the start or the child is doing. const withdrawn = await ctx.journal.rejectQueuedSubmissions( ctx.fence, - DISPATCH_REJECTION_CANCELLED + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) ) const child = context.sessions.get(ctx.sessionId)?.child if (child?.phase === 'starting') { 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 629899bb40a..2eb83529333 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 @@ -3,10 +3,7 @@ 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 { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTION_CANCELLED -} from '../../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -27,6 +24,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -236,7 +235,7 @@ describe('settling a send the provider proves it received after the ack window', sessionId: SESSION, clientMessageId: params.envelope.clientOperationId, state: 'rejected', - ...DISPATCH_REJECTION_CANCELLED + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) }) expect(submissions()).toMatchObject([ 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 6d5836a6d3e..cded7ddab48 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,10 +12,7 @@ import type { AgentSessionStatusSummary, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTION_PROVIDER_CLOSED -} from '../../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -30,6 +27,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -568,7 +567,7 @@ describe('a quit with a message still queued', () => { expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - ...DISPATCH_REJECTION_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) }) @@ -596,7 +595,7 @@ describe('a quit with a message still queued', () => { expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - ...DISPATCH_REJECTION_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index da82e3e7859..02ecb56ee75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -11,6 +11,8 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -36,8 +38,9 @@ function claudeAdapter(): StructuredAgentSessionAdapter { }), dispatch: async () => ({ state: 'rejected', - reason: 'unused', - rejection: { kind: 'providerRejected' } + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, 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 4642d2ed1c5..8b48ada94b7 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 @@ -33,6 +33,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' afterEach(() => vi.useRealTimers()) @@ -457,8 +459,9 @@ it('retains acquisition through slow continuation settlement, then releases it', expect(closeSession).not.toHaveBeenCalled() settlement.resolve({ state: 'rejected', - reason: 'provider refused', - rejection: { kind: 'providerRejected' } + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) }) expect((await continuing).continued).toMatchObject([{ outcome: 'refused' }]) expect(host.isHeld(SESSION)).toBe(false) 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 318afb6b18a..e1637f8c1ef 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 @@ -11,7 +11,7 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' -import type { AgentSessionFailureTextContext } from './structured-agent-session-failure-text' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -76,8 +76,8 @@ export async function openConversationForWrite( } /** Who a failure sentence names: the chat's agent, when the record says. */ -export function structuredAgentSessionFailureTextContext( +export function structuredAgentSessionFailureWordsContext( record: AgentSessionRecord | null -): AgentSessionFailureTextContext { +): AgentSessionFailureWordsContext { return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} } 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 f00e2386ca3..89885895dd2 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 @@ -23,6 +23,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -176,8 +178,9 @@ describe('send', () => { dispatch .mockImplementationOnce(async () => ({ state: 'rejected' as const, - reason: 'provider_write_failed: broken pipe', - rejection: { kind: 'writeFailed' as const } + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }) })) .mockImplementationOnce(async () => accepted()) const body = hostTestMessage('never written') @@ -186,7 +189,7 @@ describe('send', () => { await host.send(CALLER, params) await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ dispatchState: 'rejected', - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed' }) // 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 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 1eb2bbcd86f..1b8b17d0bd8 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 @@ -13,9 +13,11 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record. import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' -import { UNEXPECTED_PROVIDER_EXIT_OUTCOME } from './structured-agent-session-dead-generation-settlement' import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'The provider stopped while this response was in progress. You can continue in this conversation.' + const SESSION = 'session-alpha-1' const THREAD = 'thread-1' const FENCE = 8 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 3432e5915f6..10ce568b551 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,6 +1,5 @@ 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' import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' @@ -22,12 +21,8 @@ export function structuredAgentSessionStartFailureRow( return { kind: 'item', identity: { provider: 'orca', clientMessageId: `start-failure:${startKey}` }, - body: { - kind: 'status', - text: boundJournalStatusText(words.text), - tone: 'error', - failure: words.failure - } + // The row repeats the sentence the start's rejected messages carry. + body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection } } } @@ -52,8 +47,8 @@ export async function recordStructuredAgentSessionStartFailure( mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] }) await session.journal.rejectQueuedSubmissions(session.fence, { - reason: failure.text, - rejection: failure.failure + reason: failure.reason, + rejection: failure.rejection }) } 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 aad73fc8024..c08c52386a6 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 @@ -24,7 +24,6 @@ import { } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { UNEXPECTED_PROVIDER_EXIT_OUTCOME } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { HOST_TEST_NOW as NOW, @@ -35,6 +34,11 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'The provider stopped while this response was in progress. You can continue in this conversation.' const CALLER = { callerKey: 'client-1' } const SURFACE = 'desktop-chat:1' @@ -209,8 +213,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) dispatch = vi.fn(async () => ({ state: 'rejected' as const, - reason: 'unused', - rejection: { kind: 'providerRejected' as const } + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) })) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost() 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 e32f6388ebf..35d557bde56 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 @@ -1,5 +1,5 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureText } from './structured-agent-session-failure-text' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { agentSessionPromptQuestions, @@ -127,10 +127,14 @@ export async function performPrompt( throw error } // The adapter's error is Orca's; the row says only what the user needs to know. - const failure = agentSessionFailureFact('answerUnconfirmed') await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, - { kind: 'status', text: agentSessionFailureText(failure), failure }, + { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('answerUnconfirmed'), { + surface: 'row' + }) + }, { fence: ctx.fence } ) } 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 691cd9dcbcd..0dc9b8318b8 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 @@ -6,12 +6,11 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. -import { - agentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../shared/agent-session-failure' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, + AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { @@ -29,11 +28,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { - agentSessionFailureRejection, - agentSessionFailureText, - structuredAgentSessionStartFailure -} from './structured-agent-session-failure-text' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' export { performSetOption } from './structured-agent-session-turns-options' @@ -89,8 +84,7 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - const words = structuredAgentSessionStartFailure({ error }) - return { state: 'rejected', reason: words.text, rejection: words.failure } + return { state: 'rejected', ...structuredAgentSessionStartFailure({ error }) } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -99,14 +93,9 @@ async function dispatchSafely( async function appendStatus( ctx: AgentSessionTurnContext, clientMessageId: string, - text: string, - failure?: AgentSessionFailureFact + body: AgentJournalStatusItem ): Promise { - await ctx.journal.appendItem( - { provider: 'orca', clientMessageId }, - { kind: 'status', text, ...(failure ? { failure } : {}) }, - { fence: ctx.fence } - ) + await ctx.journal.appendItem({ provider: 'orca', clientMessageId }, body, { fence: ctx.fence }) } /** @@ -175,7 +164,7 @@ export async function handOverSubmission( await ctx.journal.resolveDispatch({ clientMessageId, state: 'rejected', - ...agentSessionFailureRejection(agentSessionFailureFact('hostFault')), + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), fence: ctx.fence }) return @@ -254,8 +243,7 @@ export async function performCancel( } } let cancelled = false - let note = 'Cancellation requested.' - let failure: AgentSessionFailureFact | undefined + let note: AgentJournalStatusItem = { kind: 'status', text: 'Cancellation requested.' } try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) cancelled = input.scope @@ -278,15 +266,17 @@ export async function performCancel( }) ).cancelled if (!cancelled) { - note = 'The provider had already finished this turn.' + note = { kind: 'status', text: 'The provider had already finished this turn.' } } } catch (error) { if (input.prompt) { throw error } // The adapter's error is Orca's; the row says only that the stop is unconfirmed. - failure = agentSessionFailureFact('cancelUnconfirmed') - note = agentSessionFailureText(failure) + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } } if (cancelled && input.prompt) { await ctx.flushStreamedEvents() @@ -295,6 +285,6 @@ export async function performCancel( return { ok: true, value: { turnId: input.turnId, cancelled } } } // Keyed by the operation id so a replayed cancel upserts one item, not two. - await appendStatus(ctx, input.clientOperationId, note, failure) + await appendStatus(ctx, input.clientOperationId, note) return { ok: true, value: { turnId: input.turnId, cancelled } } } 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 3475f4dc7a8..4a496424f26 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 @@ -7,7 +7,6 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { UNEXPECTED_PROVIDER_EXIT_OUTCOME } from './structured-agent-session-dead-generation-settlement' import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { isStructuredAgentSessionRecoveryTicketCurrent, @@ -17,6 +16,9 @@ import { type StructuredAgentSessionRecoveryTicket } from './structured-agent-session-unexpected-exit' +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'The provider stopped while this response was in progress. You can continue in this conversation.' + const SESSION = 'session-1' const GENERATION = 'generation-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 5663bf13789..537788bc6a4 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,5 +1,5 @@ -import type { AgentSessionFailureTextContext } from './structured-agent-session-failure-text' -import { structuredAgentSessionFailureTextContext } from './structured-agent-session-send-preparation' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' 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' @@ -118,7 +118,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, - failureTextContext: structuredAgentSessionFailureTextContext(record), + failureTextContext: structuredAgentSessionFailureWordsContext(record), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || @@ -207,7 +207,7 @@ async function retryUnexpectedExitSettlement(input: { stableSettlementId: string verdict: StructuredAgentSessionTurnVerdict exitedDuringStartup: boolean - failureTextContext: AgentSessionFailureTextContext + failureTextContext: AgentSessionFailureWordsContext showUnexpectedExitOutcome?: boolean }): Promise { return settleStructuredAgentSessionDeadGeneration({ diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts new file mode 100644 index 00000000000..4c565bcf4e2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { settleInterruptedCompaction } from './structured-compaction-recovery' + +describe('settleInterruptedCompaction', () => { + it("records the unconfirmed compaction in its fact's own words, on the row and the command", async () => { + const command = { + command: 'compact' as const, + state: 'unknown' as const, + phase: 'prepared' as const, + operationId: 'op-1', + callerKey: 'client-1', + runtimeFence: 1 + } + const setConversationCommand = vi.fn(async () => undefined) + const store = { + getRecord: () => ({ conversationCommand: command }), + setConversationCommand, + recordOperationOutcome: vi.fn(async () => undefined) + } + const appendItem = vi.fn(async () => ({ cursor: { epoch: 'e', sequence: 1 } })) + + await settleInterruptedCompaction( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function reads only the members stubbed above. + store as unknown as AgentSessionRecordStore, + 'session-1', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function calls only `appendItem`. + { appendItem } as unknown as AgentSessionJournal, + 2 + ) + + const words = { + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' } + } + expect(appendItem).toHaveBeenCalledWith( + { provider: 'orca', clientMessageId: 'compact:op-1' }, + { kind: 'status', ...words }, + { fence: 2 } + ) + expect(setConversationCommand).toHaveBeenCalledWith( + 'session-1', + 2, + expect.objectContaining({ error: words.text, failure: words.failure, state: 'unknown' }) + ) + }) +}) 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 e61d21d7c11..b581d746f62 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 @@ -1,4 +1,5 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -27,19 +28,20 @@ export async function settleInterruptedCompaction( if (command?.command !== 'compact' || command.phase !== 'prepared') { return } - const error = 'Previous compaction completion could not be confirmed after session recovery.' - const failure = agentSessionFailureFact('compactionUnconfirmed') + const words = agentSessionFailureWords(agentSessionFailureFact('compactionUnconfirmed'), { + surface: 'row' + }) await journal.appendItem( { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', text: error, failure }, + { kind: 'status', ...words }, { fence } ) const recovered = { ...command, phase: 'committed' as const, state: 'unknown' as const, - error, - failure + error: words.text, + failure: words.failure } await store.setConversationCommand(sessionId, fence, recovered) await store.recordOperationOutcome({ 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 b842c1cbd41..013e709d156 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 @@ -272,7 +272,16 @@ describe('host conversation commands', () => { }) expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ ok: true, - value: { state: 'completed', replacementSessionId: undefined, error: expect.any(String) } + value: { + state: 'completed', + replacementSessionId: undefined, + // The refusal's message is Orca's log text; the result words its situation. + error: "Codex couldn't restart. Start a new chat to continue.", + failure: { + kind: 'restartFailed', + refusal: { code: 'structured_agent_session_unsupported' } + } + } }) expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) expect(acquisitions).toBe(1) 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 fbb5520de84..6773f9b2957 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 @@ -23,8 +23,12 @@ import { agentSessionFailureFact, type AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' -import { agentSessionFailureText } from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredSessionCompactionResult } from './structured-session-compaction' /** A compaction that did not succeed, keeping only what the provider wrote for a person. */ @@ -43,15 +47,20 @@ function compactionFailure( function compactionStatusBody(failure: AgentSessionFailureFact | undefined) { return failure - ? { kind: 'status' as const, text: agentSessionFailureText(failure), failure } + ? { kind: 'status' as const, ...agentSessionFailureWords(failure, { surface: 'row' }) } : { kind: 'status' as const, text: 'Conversation compacted.' } } -function compactionCommandFailure(failure: AgentSessionFailureFact | undefined): { - error?: string - failure?: AgentSessionFailureFact -} { - return failure ? { error: agentSessionFailureText(failure).slice(0, 4096), failure } : {} +/** A command's `error` is the sentence its row shows. */ +function conversationCommandFailure( + failure: AgentSessionFailureFact | undefined, + context: AgentSessionFailureWordsContext = {} +) { + if (!failure) { + return {} + } + const words = agentSessionFailureWords(failure, { ...context, surface: 'row' }) + return { error: words.text, failure: words.failure } } export type ConversationCommandParams = { @@ -186,10 +195,12 @@ export function runStructuredConversationCommand( replacementSessionId: undefined, phase: 'committed' as const, state: 'completed' as const, - error: "The new conversation couldn't start.", - failure: agentSessionFailureFact('restartFailed', { - refusal: agentSessionRefusalReference(acquired.refusal) - }) + ...conversationCommandFailure( + agentSessionFailureFact('restartFailed', { + refusal: agentSessionRefusalReference(acquired.refusal) + }), + structuredAgentSessionFailureWordsContext(record) + ) } await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } @@ -234,7 +245,7 @@ export function runStructuredConversationCommand( ...prepared, phase: 'committed', state: 'completed', - ...compactionCommandFailure(late) + ...conversationCommandFailure(late) }) await store.recordOperationOutcome({ callerKey: caller.callerKey, @@ -265,7 +276,7 @@ export function runStructuredConversationCommand( ...prepared, phase: 'committed' as const, state: 'completed' as const, - ...compactionCommandFailure(failure) + ...conversationCommandFailure(failure) } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 07059d5b8cb..9de1925791f 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest' import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind' import { restoreRewindJournalBody } from './structured-rewind-journal-body' -/** A row this build cannot place: still a row, never its stored JSON. */ +/** A row this build cannot place: still a row, never its stored JSON, and not a failure — a + * hostFault's "Try again" would be false for a placeholder. */ const UNPLACEABLE = { kind: 'status', - text: 'Orca could not show this item after the rewind.', - failure: { kind: 'hostFault' } + text: 'Orca could not show this item after the rewind.' } describe('rewind recovery of newer durable records', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index 3e97ad5fbf0..99489b18769 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -1,4 +1,3 @@ -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' import { AGENT_JOURNAL_TURN_LIFECYCLE_STATES, @@ -14,10 +13,10 @@ type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] * and never as its stored JSON, which is Orca's record, not something a person reads. */ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { let normalized: unknown = body + // A placeholder, not a failure anyone can act on, so it carries no fact. const fallback = () => ({ kind: 'status', - text: 'Orca could not show this item after the rewind.', - failure: agentSessionFailureFact('hostFault') + text: 'Orca could not show this item after the rewind.' }) if (body.kind === 'message') { normalized = { diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index cbb0857c859..915bbad5e54 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -1,4 +1,4 @@ -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentJournalPlainStatusItem } from '../../../shared/agent-session-journal-types' import { boundInlineText, boundPayload, @@ -12,7 +12,7 @@ import { } from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { - body: AgentJournalStatusItem + body: AgentJournalPlainStatusItem /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ classification: 'timeline-substantive' | 'error-surface' } 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 be864f61df0..6b9e9bdafcc 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,6 +1,6 @@ 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' +import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -340,7 +340,7 @@ describe('structured worker dispatch preamble', () => { const error = await send( hostWithSubmission({ dispatchState: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')) + reason: DISPATCH_REJECTED_WRITE_FAILED }) ).catch((thrown: unknown) => thrown) expect((error as { code?: string }).code).toBe('dispatch_preamble_undelivered') diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 995d38c8872..79502816244 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -24,6 +24,8 @@ import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' const CONNECTION = 'connection-1' const GRACE_MS = 5 @@ -84,8 +86,9 @@ beforeEach(async () => { closeSession, dispatch: async () => ({ state: 'rejected', - reason: 'unused', - rejection: { kind: 'providerRejected' } + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts index 4fbbe816672..8b5ecb6ccf0 100644 --- a/src/shared/agent-session-conversation-command.ts +++ b/src/shared/agent-session-conversation-command.ts @@ -1,4 +1,5 @@ import type { AgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureSentence } from './agent-session-failure-words' export type AgentSessionConversationCommand = 'clear' | 'compact' @@ -6,11 +7,14 @@ export type AgentSessionConversationCommandResult = { command: AgentSessionConversationCommand state: 'completed' | 'unknown' replacementSessionId?: string - /** A sentence for a person; released clients print it as it is. */ - error?: string - /** What failed, typed; absent from older hosts. */ - failure?: AgentSessionFailureFact -} +} & + /** `error`: a sentence for a person; released clients print it as it is. */ + ( + | { error?: string; failure?: undefined } + /** What failed, typed, beside its sentence, both from `agentSessionFailureWords`; `failure` is + * absent from older hosts. */ + | { error: AgentSessionFailureSentence; failure: AgentSessionFailureFact } + ) export type AgentSessionConversationCommandRecord = AgentSessionConversationCommandResult & { runtimeFence?: number diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts new file mode 100644 index 00000000000..8588913e09f --- /dev/null +++ b/src/shared/agent-session-failure-words.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS, + AGENT_SESSION_FAILURE_KINDS, + readAgentSessionFailureFact, + type AgentSessionFailureFact, + type AgentSessionFailureKind +} from './agent-session-failure' +import { + agentSessionFailureSentence, + agentSessionFailureWords, + type AgentSessionFailureSurface +} from './agent-session-failure-words' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' + +/** What Orca's own text looks like: a code or marker, a uuid, a path, an exception. */ +const ORCA_INTERNAL = /\b[a-z]+_[a-z_]+\b|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ + +const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection'] +const LEGACY_MARKER_KINDS: ReadonlySet = new Set([ + 'cancelled', + 'writeFailed', + 'queueFull' +]) + +/** Every fact shape a kind can carry: bare, with a person or a log detail, with a refusal, and + * for an attachment each reason with and without its limit. */ +function factsFor(kind: AgentSessionFailureKind): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [ + { kind }, + { kind, detail: { text: 'Context window exceeded.', audience: 'person' } }, + { + kind, + // A log detail and a refusal ride with the fact; neither may reach the sentence. + detail: { text: 'Error: ENOENT /Users/me/.claude/x agent_session_conflict', audience: 'log' }, + refusal: { code: 'agent_session_identity_required', cause: 'claimConflicted' } + } + ] + if (kind === 'attachmentInvalid') { + for (const reason of AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS) { + facts.push({ kind, attachment: { reason } }) + facts.push({ kind, attachment: { reason, limit: 5 * 1024 * 1024 } }) + } + } + return facts +} + +describe('the words written beside a failure fact', () => { + describe.each(SURFACES)('on the %s surface', (surface) => { + it.each(AGENT_SESSION_FAILURE_KINDS)('for %s are one sentence a person can read', (kind) => { + for (const fact of factsFor(kind)) { + for (const agentName of ['Claude', undefined]) { + const sentence = agentSessionFailureSentence(fact, surface, { agentName }) + expect([fact, sentence]).toEqual([fact, expect.stringMatching(/[^.]\.$/)]) + expect(sentence).not.toMatch(/\.\./) + expect(sentence).not.toMatch(ORCA_INTERNAL) + if (surface === 'rejection' && LEGACY_MARKER_KINDS.has(kind)) { + continue + } + const context = { agentName, provider: 'claude' } as const + const written = + surface === 'row' + ? agentSessionFailureWords(fact, { ...context, surface }).text + : agentSessionFailureWords(fact, { ...context, surface }).reason + expect(written).toBe(sentence) + } + } + }) + }) + + it('writes the legacy markers released clients hide, byte for byte, on a rejection', () => { + const reason = (kind: AgentSessionFailureKind, provider?: 'claude' | 'codex') => + agentSessionFailureWords({ kind }, { surface: 'rejection', provider }).reason + expect(reason('cancelled')).toBe(DISPATCH_REJECTED_CANCELLED) + expect(DISPATCH_REJECTED_CANCELLED).toBe('provider_cancelled_before_start') + expect(reason('queueFull', 'claude')).toBe(DISPATCH_REJECTED_QUEUE_FULL) + expect(DISPATCH_REJECTED_QUEUE_FULL).toBe('claude structured dispatch queue is full') + expect(reason('queueFull', 'codex')).toBe(DISPATCH_REJECTED_CODEX_QUEUE_FULL) + expect(DISPATCH_REJECTED_CODEX_QUEUE_FULL).toBe('codex structured dispatch queue is full') + // Bare: the error that once followed it belongs in the log. + expect(reason('writeFailed')).toBe(DISPATCH_REJECTED_WRITE_FAILED) + expect(DISPATCH_REJECTED_WRITE_FAILED).toBe('provider_write_failed') + // With no provider to name its marker, a full queue gets a sentence. + expect(reason('queueFull')).toBe( + 'Too many messages were waiting for the agent, so this one was not sent.' + ) + for (const kind of LEGACY_MARKER_KINDS) { + const written = agentSessionFailureWords( + { kind }, + { surface: 'rejection', provider: 'codex' } + ) + expect(classifyDispatchRejection({ reason: written.reason })).toMatchObject({ kind }) + } + }) + + it('names the exit a row reports differently from the message it left unsent', () => { + expect(agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row' }).text).toBe( + 'The provider stopped while this response was in progress. You can continue in this conversation.' + ) + expect( + agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'rejection' }).reason + ).toBe('The provider stopped before this message was sent.') + }) + + it('says which limit an attachment broke, in megabytes', () => { + const sentence = (attachment: AgentSessionFailureFact['attachment']) => + agentSessionFailureSentence({ kind: 'attachmentInvalid', attachment }, 'rejection', { + agentName: 'Claude' + }) + expect(sentence({ reason: 'tooLarge', limit: 5 * 1024 * 1024 })).toBe( + 'An image on this message is larger than 5 MB, so the message was not sent.' + ) + expect(sentence({ reason: 'totalTooLarge', limit: 20 * 1024 * 1024 })).toBe( + 'The images on this message add up to more than 20 MB, so the message was not sent.' + ) + expect(sentence({ reason: 'tooMany', limit: 20 })).toBe( + 'Claude accepts at most 20 images in one message, so this message was not sent.' + ) + expect(sentence(undefined)).toBe("An attachment on this message can't be sent to the agent.") + }) +}) + +describe('an attachment problem as a reader meets it', () => { + it('keeps what it can place and drops what it cannot', () => { + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'tooMany', limit: 20 } + }) + ).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } }) + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'tooMany', limit: 'twenty' } + }) + ).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany' } }) + // A reason a newer host added reads as the generic attachment failure. + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'futureReason' } + }) + ).toEqual({ kind: 'attachmentInvalid' }) + }) +}) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts new file mode 100644 index 00000000000..3d43e29e811 --- /dev/null +++ b/src/shared/agent-session-failure-words.ts @@ -0,0 +1,221 @@ +// The sentence a person reads beside each failure fact, and the one constructor that writes both. +// +// A row's `text`, a rejected message's `reason` and a conversation command's `error` are what +// every released client prints as they are, so the host writes them here, from the fact, and +// nowhere else: never Orca's own error text, a refusal's message, or a probe's evidence. A +// provider's words reach the sentence only when the provider wrote them for a person. The table +// is also the English default for a client that chooses its own copy from the fact. + +import type { + AgentSessionAttachmentProblem, + AgentSessionAttachmentProblemReason, + AgentSessionFailureFact, + AgentSessionFailureKind, + ProviderDiagnostic +} from './agent-session-failure' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' + +declare const failureSentence: unique symbol + +/** Words only `agentSessionFailureWords` makes, so no writer can put its own beside a fact. */ +export type AgentSessionFailureSentence = string & { readonly [failureSentence]: true } + +/** A status row that reports a failure. */ +export type AgentSessionFailureRowWords = { + text: AgentSessionFailureSentence + failure: AgentSessionFailureFact +} + +/** A rejection as the host writes it: the sentence (or, for the cases released clients already + * hide, the legacy marker) they print, and the fact newer ones read. */ +export type AgentJournalDispatchRejection = { + reason: AgentSessionFailureSentence + rejection: AgentSessionFailureFact +} + +/** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. */ +export type AgentSessionFailureSurface = 'row' | 'rejection' + +export type AgentSessionFailureWordsContext = { + /** The chat's agent, when the writer knows it. */ + agentName?: string + /** Names the legacy queue-full marker; without it a full queue is worded as a sentence. */ + provider?: 'claude' | 'codex' +} + +/** + * Whether a refused start leaves the chat anything to start again from. `false`: 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. + */ +export 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 +} + +/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ +const MAX_QUOTED_DETAIL_CHARS = 512 +const BYTES_PER_MB = 1024 * 1024 + +type Sentence = ( + context: AgentSessionFailureWordsContext, + fact: AgentSessionFailureFact, + surface: AgentSessionFailureSurface +) => string + +function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefined): string { + const quoted = + detail?.audience === 'person' + ? detail.text + .slice(0, MAX_QUOTED_DETAIL_CHARS) + .trim() + .replace(/[.\s]+$/, '') + : '' + return quoted ? `${lead}: ${quoted}.` : `${lead}.` +} + +function couldNot(verb: string): Sentence { + return ({ agentName }, fact) => { + const failed = `${agentName ?? 'The agent'} couldn't ${verb}.` + const code = fact.refusal?.code + return code && !START_REFUSAL_RESUMABLE[code] + ? `${failed} Start a new chat to continue.` + : failed + } +} + +function megabytes(bytes: number): string { + return `${Math.round((bytes / BYTES_PER_MB) * 10) / 10} MB` +} + +const NOT_SENT = 'so the message was not sent.' + +const ATTACHMENT_SENTENCES = { + empty: () => `An image on this message is empty, ${NOT_SENT}`, + tooLarge: (_, { limit }) => + limit + ? `An image on this message is larger than ${megabytes(limit)}, ${NOT_SENT}` + : `An image on this message is too large, ${NOT_SENT}`, + tooMany: ({ agentName }, { limit }) => + limit + ? `${agentName ?? 'The agent'} accepts at most ${limit} images in one message, so this message was not sent.` + : 'This message has too many images, so it was not sent.', + totalTooLarge: (_, { limit }) => + limit + ? `The images on this message add up to more than ${megabytes(limit)}, ${NOT_SENT}` + : `The images on this message are too large together, ${NOT_SENT}`, + unsupportedType: ({ agentName }) => + `${agentName ?? 'The agent'} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.`, + notAFile: () => `An image on this message isn't a file, ${NOT_SENT}`, + noSource: () => `An image on this message has no file to send, ${NOT_SENT}` +} satisfies Record< + AgentSessionAttachmentProblemReason, + (context: AgentSessionFailureWordsContext, problem: AgentSessionAttachmentProblem) => string +> + +const FAILURE_SENTENCES = { + providerStartFailed: () => 'The provider stopped before it finished starting.', + notSignedIn: ({ agentName }) => + `${agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then send your message again.`, + historyTooLarge: () => + "This conversation's history is too large to restore here. Start a new chat to continue.", + providerExited: (_, __, surface) => + surface === 'row' + ? 'The provider stopped while this response was in progress. You can continue in this conversation.' + : 'The provider stopped before this message was sent.', + restartFailed: couldNot('restart'), + providerRejected: (_, fact) => + quotingPersonDetail('The provider did not accept this message', fact.detail), + attachmentInvalid: (context, fact) => + fact.attachment + ? ATTACHMENT_SENTENCES[fact.attachment.reason](context, fact.attachment) + : "An attachment on this message can't be sent to the agent.", + attachmentUnreadable: () => `An attachment on this message couldn't be read, ${NOT_SENT}`, + emptyMessage: () => 'This message is empty, so it was not sent.', + queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.', + writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", + cancelled: () => 'This message was withdrawn before the agent started it.', + chatClosed: () => 'The chat closed before this message was sent.', + hostRestarted: () => 'Orca restarted before this message was sent.', + notDelivered: () => 'This message was not delivered. Send it again to continue.', + compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), + compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', + cancelUnconfirmed: () => 'Cancellation was not confirmed.', + answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', + hostFault: () => "Orca ran into a problem, so this didn't go through. Try again." +} satisfies Record + +/** The sentence a person reads for this fact on this surface; never a marker. */ +export function agentSessionFailureSentence( + fact: AgentSessionFailureFact, + surface: AgentSessionFailureSurface, + context: AgentSessionFailureWordsContext = {} +): string { + const sentence: Sentence = FAILURE_SENTENCES[fact.kind] + return sentence(context, fact, surface) +} + +/** The marker released clients hide, for the rejections that had one before rows carried a fact. + * A write failure is the bare marker: its error belongs in the log. */ +function legacyRejectionMarker( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext +): string | undefined { + switch (fact.kind) { + case 'cancelled': + return DISPATCH_REJECTED_CANCELLED + case 'writeFailed': + return DISPATCH_REJECTED_WRITE_FAILED + case 'queueFull': + return context.provider === 'codex' + ? DISPATCH_REJECTED_CODEX_QUEUE_FULL + : context.provider === 'claude' + ? DISPATCH_REJECTED_QUEUE_FULL + : undefined + default: + return undefined + } +} + +/** The words a status row reporting this fact records. */ +export function agentSessionFailureWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext & { surface: 'row' } +): AgentSessionFailureRowWords +/** The words a message rejected for this fact records. */ +export function agentSessionFailureWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext & { surface: 'rejection' } +): AgentJournalDispatchRejection +export function agentSessionFailureWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext & { surface: AgentSessionFailureSurface } +): AgentSessionFailureRowWords | AgentJournalDispatchRejection { + const words = + (context.surface === 'rejection' ? legacyRejectionMarker(fact, context) : undefined) ?? + agentSessionFailureSentence(fact, context.surface, context) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the one constructor of the brand; `words` came from the table or a legacy marker for `fact`. + const sentence = words as AgentSessionFailureSentence + return context.surface === 'row' + ? { text: sentence, failure: fact } + : { reason: sentence, rejection: fact } +} diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 55bcd3d484c..a9b6ca66758 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -22,6 +22,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'providerRejected', 'attachmentInvalid', 'attachmentUnreadable', + 'emptyMessage', 'queueFull', 'writeFailed', 'cancelled', @@ -53,12 +54,33 @@ export type ProviderDiagnostic = { * lease record keeps, so a diagnostic never outgrows what the record may store. */ export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 512 +/** Which of Orca's checks an image failed. A new one needs a sentence before it compiles. */ +export const AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS = [ + 'empty', + 'tooLarge', + 'tooMany', + 'totalTooLarge', + 'unsupportedType', + 'notAFile', + 'noSource' +] as const +export type AgentSessionAttachmentProblemReason = + (typeof AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS)[number] + +export type AgentSessionAttachmentProblem = { + reason: AgentSessionAttachmentProblemReason + /** The limit it broke: bytes for `tooLarge` and `totalTooLarge`, a count for `tooMany`. */ + limit?: number +} + export type AgentSessionFailureFact = { kind: AgentSessionFailureKind /** Provider-authored only; absent whenever Orca wrote the words. */ detail?: ProviderDiagnostic /** On `restartFailed`: the refusal that kept the agent from starting. */ refusal?: AgentSessionRefusalReference + /** On `attachmentInvalid`: which check the attachment failed. */ + attachment?: AgentSessionAttachmentProblem } /** Null for empty text, so a writer never records a detail with nothing in it. */ @@ -72,7 +94,11 @@ export function providerDiagnostic( export function agentSessionFailureFact( kind: AgentSessionFailureKind, - extra: { detail?: ProviderDiagnostic; refusal?: AgentSessionRefusalReference } = {} + extra: { + detail?: ProviderDiagnostic + refusal?: AgentSessionRefusalReference + attachment?: AgentSessionAttachmentProblem + } = {} ): AgentSessionFailureFact { // Re-bounded here, so no writer can store more than the cap however it built the detail. const detail = extra.detail @@ -81,7 +107,8 @@ export function agentSessionFailureFact( return { kind, ...(detail ? { detail } : {}), - ...(extra.refusal ? { refusal: extra.refusal } : {}) + ...(extra.refusal ? { refusal: extra.refusal } : {}), + ...(extra.attachment ? { attachment: extra.attachment } : {}) } } @@ -97,6 +124,20 @@ export function isProviderDiagnostic(value: unknown): value is ProviderDiagnosti ) } +function readAttachmentProblem(value: unknown): AgentSessionAttachmentProblem | undefined { + if (!isRecord(value)) { + return undefined + } + const reason = AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS.find((known) => known === value.reason) + if (!reason) { + return undefined + } + const limit = value.limit + return typeof limit === 'number' && Number.isFinite(limit) && limit > 0 + ? { reason, limit } + : { reason } +} + /** A fact as a reader meets it. Undefined for anything this build cannot place, including a kind a * newer host added, so the reader falls back to what it does for a row with no fact. */ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined { @@ -110,9 +151,11 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure ...(isAgentSessionRefusalCause(value.refusal.cause) ? { cause: value.refusal.cause } : {}) } : undefined + const attachment = readAttachmentProblem(value.attachment) return agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), - ...(refusal ? { refusal } : {}) + ...(refusal ? { refusal } : {}), + ...(attachment ? { attachment } : {}) }) } diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index ceace088c6a..65a0e6c3288 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -8,6 +8,7 @@ // journal rather than skipping or compacting past it. import type { AgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureRowWords } from './agent-session-failure-words' import type { AgentType } from './agent-status-types' import type { AgentSessionQuestionAnswer } from './agent-session-question-answer' import type { AgentJournalTurnOutcome } from './agent-turn-outcome' @@ -258,9 +259,8 @@ export type AgentJournalThreadGoalState = | { state: 'set'; goal: AgentJournalThreadGoal } | { state: 'cleared' } -export type AgentJournalStatusItem = { +type AgentJournalStatusItemFields = { kind: 'status' - text: string /** Optional display hints; unknown values retain the ordinary text fallback. */ presentation?: string tone?: string @@ -277,11 +277,21 @@ export type AgentJournalStatusItem = { } /** Present on thread-goal transitions; absent on rows from older hosts. */ threadGoal?: AgentJournalThreadGoalState - /** On a row that reports a failure: what failed, typed. `text` stays the sentence older - * clients print; absent on rows from older hosts. */ - failure?: AgentSessionFailureFact } +/** A status row that reports no failure; its text is its writer's own. */ +export type AgentJournalPlainStatusItem = AgentJournalStatusItemFields & { + text: string + failure?: undefined +} + +export type AgentJournalStatusItem = + | AgentJournalPlainStatusItem + | (AgentJournalStatusItemFields & + /** A row that reports a failure: what failed, typed, beside the sentence older clients print, + * both from `agentSessionFailureWords`. Absent on rows from older hosts. */ + AgentSessionFailureRowWords) + /** The durable record of one root turn. `running` exposes cancellation while * the provider can still accept it; the item is revised to a terminal state, * never tombstoned, so the endpoints survive. Timestamps are the execution diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index 63a6cb12694..a8a5cd27fa8 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_FAILURE_KINDS } from './agent-session-failure' +import { AGENT_SESSION_FAILURE_KINDS, agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' import { classifyDispatchRejection, DISPATCH_REJECTED_CANCELLED, @@ -7,9 +8,6 @@ import { DISPATCH_REJECTED_HOST_RESTARTED, DISPATCH_REJECTED_PROVIDER_CLOSED, DISPATCH_REJECTED_QUEUE_FULL, - DISPATCH_REJECTION_HOST_RESTARTED, - DISPATCH_REJECTION_PROVIDER_CLOSED, - dispatchWriteFailureReason, isWriteFailureSubmission } from './structured-agent-session-dispatch-rejection' @@ -21,7 +19,7 @@ describe('classifyDispatchRejection', () => { ['not_delivered', 'undelivered', 'failure', 'notDelivered'], [DISPATCH_REJECTED_QUEUE_FULL, 'transport', 'failure', 'queueFull'], [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'transport', 'failure', 'queueFull'], - [dispatchWriteFailureReason(new Error('broken pipe')), 'transport', 'failure', 'writeFailed'], + ['provider_write_failed: broken pipe', 'transport', 'failure', 'writeFailed'], ['provider_write_failed', 'transport', 'failure', 'writeFailed'] ] as const)('reads the legacy marker %j', (reason, category, verdict, kind) => { expect(classifyDispatchRejection({ reason })).toEqual({ category, verdict, kind }) @@ -61,10 +59,10 @@ describe('classifyDispatchRejection', () => { }) it('writes a sentence, not a marker released clients would print, for a restart or a close', () => { - for (const [written, kind] of [ - [DISPATCH_REJECTION_HOST_RESTARTED, 'hostRestarted'], - [DISPATCH_REJECTION_PROVIDER_CLOSED, 'chatClosed'] - ] as const) { + for (const kind of ['hostRestarted', 'chatClosed'] as const) { + const written = agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection' + }) expect(written.reason).not.toMatch(/^[a-z_]+$/) expect(classifyDispatchRejection(written)).toEqual({ category: 'undelivered', @@ -84,8 +82,17 @@ describe('classifyDispatchRejection', () => { describe('isWriteFailureSubmission', () => { it('holds for a legacy row in any state that carries the marker', () => { - const reason = dispatchWriteFailureReason(new Error('closed before enqueue')) - expect(isWriteFailureSubmission({ reason })).toBe(true) + expect( + isWriteFailureSubmission({ reason: 'provider_write_failed: closed before enqueue' }) + ).toBe(true) + }) + + it('holds for the bare marker a new host writes, as released clients already read it', () => { + const written = agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }) + expect(written.reason).toBe('provider_write_failed') + expect(isWriteFailureSubmission({ reason: written.reason })).toBe(true) }) it('holds for a typed write failure and for nothing else', () => { diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 90d2128c21a..f80680e727a 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -2,20 +2,18 @@ // // The host writes each rejection twice: `reason`, a sentence (or, for the cases older clients // already recognise, one of the legacy markers below) that released clients print as it is, and -// `rejection`, the typed fact newer clients read. `classifyDispatchRejection` is the only place a -// reader judges either; the markers are exported for the writers that must keep writing them. +// `rejection`, the typed fact newer clients read — both from `agentSessionFailureWords`. +// `classifyDispatchRejection` is the only place a reader judges either. // // Every rejection makes the one claim that state exists to make: this message did not reach the // provider. None is ever re-delivered under its own id — `rejected` is terminal in the reducer — so // a retry rotates the client message id, which is a new message and cannot duplicate. -import { - readAgentSessionFailureFact, - type AgentSessionFailureFact, - type AgentSessionFailureKind -} from './agent-session-failure' +import { readAgentSessionFailureFact, type AgentSessionFailureKind } from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' +/** Orca could not hand the message over. Written bare: released clients hide it, and the error + * that once followed it belongs in the log. Rows from older hosts carry `: ` after it. */ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' /** Local admission refused the frame before any transport was involved. Two @@ -35,51 +33,6 @@ export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery' * Read only, like the one above. */ 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}` -} - -/** A rejection as the host writes it: the sentence (or legacy marker) released clients print, and - * the typed fact newer ones read. */ -export type AgentJournalDispatchRejection = { - reason: string - rejection: AgentSessionFailureFact -} - -export const DISPATCH_REJECTION_CANCELLED: AgentJournalDispatchRejection = { - reason: DISPATCH_REJECTED_CANCELLED, - rejection: { kind: 'cancelled' } -} - -export const DISPATCH_REJECTION_HOST_RESTARTED: AgentJournalDispatchRejection = { - reason: 'Orca restarted before this message was sent.', - rejection: { kind: 'hostRestarted' } -} - -export const DISPATCH_REJECTION_PROVIDER_CLOSED: AgentJournalDispatchRejection = { - reason: 'The chat closed before this message was sent.', - rejection: { kind: 'chatClosed' } -} - -/** Restart reconciliation proved the provider never took it. Released clients printed the old - * `not_delivered` marker as it was, so new rows carry a sentence instead. */ -export const DISPATCH_REJECTION_NOT_DELIVERED: AgentJournalDispatchRejection = { - reason: 'This message was not delivered. Send it again to continue.', - rejection: { kind: 'notDelivered' } -} - -/** The marker keeps its Orca text after the prefix, as released clients expect; they hide it. */ -export function dispatchWriteFailureRejection(error: unknown): AgentJournalDispatchRejection { - return { reason: dispatchWriteFailureReason(error), rejection: { kind: 'writeFailed' } } -} - -export function dispatchQueueFullRejection( - marker: typeof DISPATCH_REJECTED_QUEUE_FULL | typeof DISPATCH_REJECTED_CODEX_QUEUE_FULL -): AgentJournalDispatchRejection { - return { reason: marker, rejection: { kind: 'queueFull' } } -} - /** True for the internal transport marker, false for a provider's own words. Legacy-reason half * of `isWriteFailureSubmission`; readers go through that or the classifier. */ function dispatchRejectionWasTransportWriteFailure(reason: string | null | undefined): boolean { @@ -132,6 +85,7 @@ const KIND_CATEGORY = { providerRejected: 'content', attachmentInvalid: 'content', attachmentUnreadable: 'content', + emptyMessage: 'content', queueFull: 'transport', writeFailed: 'transport', hostFault: 'transport', diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 18c26700d9a..fc9b538140c 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' +import { agentSessionFailureWords } from './agent-session-failure-words' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { parsePaneKey } from './stable-pane-id' import { @@ -104,15 +105,16 @@ describe('structured agent session status projection', () => { }) it("forwards a status row's failure fact and drops one this build cannot place", () => { - const failure = { - kind: 'providerExited' as const, - detail: { text: 'stderr tail', audience: 'log' as const } - } + const words = agentSessionFailureWords( + { + kind: 'providerExited', + detail: { text: 'stderr tail', audience: 'log' } + }, + { surface: 'row' } + ) expect( - projectStructuredItemToNativeChat( - item('exit', 1, { kind: 'status', text: 'Stopped.', failure }) - )?.blocks[0] - ).toEqual({ type: 'text', text: 'Stopped.', failure }) + projectStructuredItemToNativeChat(item('exit', 1, { kind: 'status', ...words }))?.blocks[0] + ).toEqual({ type: 'text', ...words }) const future = item('future', 2, { kind: 'status', text: 'Stopped.' }) // A newer host's kind reads as no fact, so the row keeps its text and nothing else. Object.assign(future.body, { failure: { kind: 'futureKind' } }) diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 5858d41a751..09f749bf38f 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -8,7 +8,6 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { - dispatchWriteFailureReason, DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' @@ -72,7 +71,7 @@ describe('what a rejection shows the user', () => { }) it('never puts the transport marker on screen', () => { - const shown = notice(dispatchWriteFailureReason(new Error('broken pipe'))) + const shown = notice('provider_write_failed: broken pipe') // `provider_write_failed: broken pipe` names nothing a person can act on. expect(shown).not.toContain('provider_write_failed') expect(shown).not.toContain('broken pipe') From d19f909a9b6fd7a794e081818aac6694a48253f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:33:29 -0700 Subject: [PATCH 071/122] fix(native-chat): a start that failed without an observed exit no longer blames the provider Every untyped start error was recorded as "The provider stopped before it finished starting.", so an Orca fault, a failed spawn or a close that ended a start was blamed on the provider. Only an exit the adapter observed says so now: the Claude adapter marks the error it saw the child exit with, and anything else is a new `startFailed` kind, " couldn't start.", keeping the provider's diagnostic when the error carried one. A child gone with no end observed, and a /clear whose new conversation was refused, read the same way. --- ...laude-structured-session-exit-lifecycle.ts | 8 +++- .../structured-agent-session-delivery-loop.ts | 3 +- ...uctured-agent-session-failure-text.test.ts | 41 +++++++++++++++---- .../structured-agent-session-failure-text.ts | 26 ++++++++++-- .../structured-conversation-command.test.ts | 7 +--- .../structured-conversation-command.ts | 2 +- src/shared/agent-session-failure-words.ts | 1 + src/shared/agent-session-failure.ts | 5 ++- ...ctured-agent-session-dispatch-rejection.ts | 1 + 9 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 275cdfca3d0..17a86c1b2e1 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,5 +1,8 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { + providerStartupFailureFact, + withObservedProviderExit +} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, @@ -34,7 +37,8 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) - failClaudeStartup(session, error) + // Observed here: whoever meets this error later, a start it ended reads as the provider stopping. + failClaudeStartup(session, withObservedProviderExit(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/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 ddcbf86189f..fcda3174408 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 @@ -187,7 +187,8 @@ export class StructuredAgentSessionDeliveryLoop { startKey: awaited?.generation ?? null, cause: ended ? endedChildFailure(ended) - : { failure: startFailure ?? agentSessionFailureFact('providerStartFailed') } + : // Gone with no end observed: nothing says the provider stopped. + { failure: startFailure ?? agentSessionFailureFact('startFailed') } }) } const next = oldestQueuedSubmission(session) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index 76d98524eb0..3b0adbb9882 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -7,7 +7,10 @@ import { import { AgentSessionRefusalError, refuse } from '../../../shared/agent-session-wire-refusals' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + structuredAgentSessionStartFailure, + withObservedProviderExit +} from './structured-agent-session-failure-text' /** What Orca's own error text looks like: a code, a marker, a uuid, a path, an exception. */ const ORCA_INTERNAL = @@ -21,18 +24,15 @@ describe('structuredAgentSessionStartFailure', () => { ) expect(structuredAgentSessionStartFailure({ error: carried }, { agentName: 'Claude' })).toEqual( { - reason: 'The provider stopped before it finished starting.', - rejection: { - kind: 'providerStartFailed', - detail: { text: 'code 1\nboom', audience: 'log' } - } + reason: "Claude couldn't start.", + rejection: { kind: 'startFailed', detail: { text: 'code 1\nboom', audience: 'log' } } } ) // Orca's own words, however provider-like, are never promoted to a detail. expect( structuredAgentSessionStartFailure({ error: new Error('Not logged in. Run /login.') }) .rejection - ).toEqual({ kind: 'providerStartFailed' }) + ).toEqual({ kind: 'startFailed' }) }) it('keeps a start refusal the adapter typed', () => { @@ -100,6 +100,33 @@ describe('structuredAgentSessionStartFailure', () => { ).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) }) + it('says the provider stopped only when an exit was observed', () => { + const exited = structuredAgentSessionStartFailure({ + refusal: { + ...refuse('agent_session_ownership_unknown', 'ownerUnproven', 'probe saw an exit'), + ownerVerdict: 'exited' + } + }) + expect(exited).toEqual({ + reason: 'The provider stopped before it finished starting.', + rejection: { kind: 'providerStartFailed' } + }) + // A start that threw proves nothing about the provider: it may be Orca's, or a failed spawn. + expect( + structuredAgentSessionStartFailure({ error: new Error('spawn claude ENOENT') }, {}) + ).toEqual({ reason: "The agent couldn't start.", rejection: { kind: 'startFailed' } }) + // The same error, once the adapter that observed the child's exit marked it, blames the provider. + const exit = withObservedProviderExit( + withProviderDiagnostic(new Error('exited (code 1)'), providerDiagnostic('code 1', 'log')) + ) + expect( + structuredAgentSessionStartFailure({ error: new Error('wrapped', { cause: exit }) }) + ).toEqual({ + reason: 'The provider stopped before it finished starting.', + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } + }) + }) + it('reads a thrown refusal the same as a returned one', () => { const thrown = new AgentSessionRefusalError( refuse('agent_session_conflict', 'claimConflicted', 'Another process claims this session.') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index f210604f511..2e11b12c12b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -18,8 +18,25 @@ import { } from '../../../shared/agent-session-wire-refusals' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' -/** A start that did not land. A refusal the adapter typed keeps its situation; anything else is a - * start that failed, with the provider's diagnostic when the error carried one. */ +/** Marks the error an adapter observed its child's exit with, where it observed it. */ +export function withObservedProviderExit(error: TError): TError { + return Object.assign(error, { providerExitObserved: true }) +} + +function providerExitObserved(error: unknown): boolean { + let current: unknown = error + for (let depth = 0; depth < 6 && current instanceof Error; depth += 1) { + if ('providerExitObserved' in current && current.providerExitObserved === true) { + return true + } + current = current.cause + } + return false +} + +/** A start that did not land. A refusal the adapter typed keeps its situation, and an exit the + * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or + * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ export function providerStartupFailureFact(cause?: unknown): AgentSessionFailureFact { if ( cause instanceof AgentSessionAcquisitionRefusal && @@ -27,7 +44,10 @@ export function providerStartupFailureFact(cause?: unknown): AgentSessionFailure ) { return agentSessionFailureFact(cause.refusalCause) } - return agentSessionFailureFact('providerStartFailed', { detail: providerDiagnosticOf(cause) }) + return agentSessionFailureFact( + providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', + { detail: providerDiagnosticOf(cause) } + ) } /** A child that ended before it proved its start: an exit is a start that failed, keeping the 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 013e709d156..8d8b9b09e51 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 @@ -276,11 +276,8 @@ describe('host conversation commands', () => { state: 'completed', replacementSessionId: undefined, // The refusal's message is Orca's log text; the result words its situation. - error: "Codex couldn't restart. Start a new chat to continue.", - failure: { - kind: 'restartFailed', - refusal: { code: 'structured_agent_session_unsupported' } - } + error: "Codex couldn't start. Start a new chat to continue.", + failure: { kind: 'startFailed', refusal: { code: 'structured_agent_session_unsupported' } } } }) expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) 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 6773f9b2957..5d7f03e32ad 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 @@ -196,7 +196,7 @@ export function runStructuredConversationCommand( phase: 'committed' as const, state: 'completed' as const, ...conversationCommandFailure( - agentSessionFailureFact('restartFailed', { + agentSessionFailureFact('startFailed', { refusal: agentSessionRefusalReference(acquired.refusal) }), structuredAgentSessionFailureWordsContext(record) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 3d43e29e811..4f86719050f 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -134,6 +134,7 @@ const ATTACHMENT_SENTENCES = { const FAILURE_SENTENCES = { providerStartFailed: () => 'The provider stopped before it finished starting.', + startFailed: couldNot('start'), notSignedIn: ({ agentName }) => `${agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then send your message again.`, historyTooLarge: () => diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index a9b6ca66758..91c18a5eabb 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -14,7 +14,10 @@ import { /** One per situation with its own honest next step; a new one needs copy before it compiles. */ export const AGENT_SESSION_FAILURE_KINDS = [ + /** Only an observed exit: the provider stopped while starting. */ 'providerStartFailed', + /** A start that did not land, with no one to blame: Orca's fault, a failed spawn, a close. */ + 'startFailed', 'notSignedIn', 'historyTooLarge', 'providerExited', @@ -77,7 +80,7 @@ export type AgentSessionFailureFact = { kind: AgentSessionFailureKind /** Provider-authored only; absent whenever Orca wrote the words. */ detail?: ProviderDiagnostic - /** On `restartFailed`: the refusal that kept the agent from starting. */ + /** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. */ refusal?: AgentSessionRefusalReference /** On `attachmentInvalid`: which check the attachment failed. */ attachment?: AgentSessionAttachmentProblem diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index f80680e727a..ef3cebeeeaf 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -79,6 +79,7 @@ const KIND_CATEGORY = { notDelivered: 'undelivered', providerExited: 'undelivered', providerStartFailed: 'startFailed', + startFailed: 'startFailed', notSignedIn: 'startFailed', historyTooLarge: 'startFailed', restartFailed: 'startFailed', 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 072/122] 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 da6c341ce87..e12acb3a12e 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 974a69b1c3c..b1004f7e396 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 9b6718e9957..822f36df5bf 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 0acbccb6b55..89f28b890ed 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 bc11a0a8de9..49d8b24e644 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 11cd824d006..6462fbe5ad7 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 1a792e600b4..6c469e4dacc 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 80ddf2e3549..541daec0153 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 00000000000..aac234cd52b --- /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 7382a717dbe421cd4eb58f56171e384c16245e3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:50:34 -0700 Subject: [PATCH 073/122] fix(native-chat): a chat a terminal agent still holds says to quit that agent The merged base words a restart a terminal agent's claim refused with the refusal's own message, which names the process. That message is Orca's text and never reaches a durable row here, so the row read only " couldn't restart." and lost the one step that frees the chat. The restart sentence now derives it from the refusal's cause: a claimConflicted refusal adds "This chat is still open in a terminal agent. Quit that agent to continue the chat here." The live refusal still names the process. The restart-resume ledger test now sets up a claim the base still refuses: a terminal owner that is proven running. --- ...ed-agent-session-legacy-handoff-record.test.ts | 7 +++++-- ...t-session-restart-interruption-test-harness.ts | 9 +++++++-- ...ctured-agent-session-restart-ownership.test.ts | 12 ++++++++++-- src/shared/agent-session-failure-words.test.ts | 15 +++++++++++++++ src/shared/agent-session-failure-words.ts | 4 ++++ 5 files changed, 41 insertions(+), 6 deletions(-) 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 f61dd491054..ab5acfa5d34 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 @@ -245,7 +245,8 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: 'recovering' }) // Sending and opening the chat both say what frees it: quitting that terminal agent. A send is - // accepted, then rejected by the start that cannot take the lease, and the chat's row says why. + // accepted, then rejected by the start that cannot take the lease, and the chat's row says why, + // worded from the refusal's cause; only the live refusal names the process. const quitTerminal = 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' expect(await delivered('while the terminal still runs')).toMatchObject({ @@ -257,7 +258,9 @@ describe('a record an older build left mid terminal handoff', () => { .items.flatMap((item) => item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] ) - ).toEqual([expect.stringContaining(quitTerminal)]) + ).toEqual([ + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ]) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, 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 1d7f0033a15..d11415d7973 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 @@ -11,6 +11,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { adapter, @@ -31,7 +32,11 @@ export const GRACE = 15_000 export async function interruptedRestart( work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', - historyBoundaryConsistent = true + historyBoundaryConsistent = true, + /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ + probeOwner: NonNullable = async () => ({ + outcome: 'pid-absent' + }) ) { const previous = hostTestState() await attach() @@ -116,7 +121,7 @@ export async function interruptedRestart( journalRoot: previous.root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner, recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), releaseGraceMs: GRACE, now: () => NOW 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 8b48ada94b7..5ab80926b35 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 @@ -720,10 +720,18 @@ it('logs teardown capsule publication failure and still releases the provider', // The resume ledger's reason stays the refusal code, which is what every renderer's guidance keys // on; the cause is filed beside it, never in its place. it('files a restart refused by a conflicted claim under its code, with its cause beside it', async () => { - const { host, store } = await interruptedRestart() + // The terminal agent that holds the claim is still running, so nothing may take it over. + const { host, store } = await interruptedRestart('turn', true, async () => ({ + outcome: 'identity-matched', + matchedOn: ['spawn-token'] + })) await store.transitionHandoff(SESSION, (record) => ({ ...record, - lease: { ...record.lease, claimStatus: 'conflicted' } + lease: { + ...record.lease, + claimStatus: 'conflicted', + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'terminal' } + } })) await host.restartResume.continueAfterRestart([SESSION], 'modal') diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 8588913e09f..5495c5eb21d 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -108,6 +108,21 @@ describe('the words written beside a failure fact', () => { ).toBe('The provider stopped before this message was sent.') }) + it('says what frees a chat a terminal agent still holds, without naming its process', () => { + expect( + agentSessionFailureSentence( + { + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + }, + 'row', + { agentName: 'Codex' } + ) + ).toBe( + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ) + }) + it('says which limit an attachment broke, in megabytes', () => { const sentence = (attachment: AgentSessionFailureFact['attachment']) => agentSessionFailureSentence({ kind: 'attachmentInvalid', attachment }, 'rejection', { diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 4f86719050f..0dbe35d351e 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -96,6 +96,10 @@ function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefine function couldNot(verb: string): Sentence { return ({ agentName }, fact) => { const failed = `${agentName ?? 'The agent'} couldn't ${verb}.` + // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. + if (fact.refusal?.cause === 'claimConflicted') { + return `${failed} This chat is still open in a terminal agent. Quit that agent to continue the chat here.` + } const code = fact.refusal?.code return code && !START_REFUSAL_RESUMABLE[code] ? `${failed} Start a new chat to continue.` From 4c41ba580a2e7fbff18e5766be05134ff67463eb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:52:58 -0700 Subject: [PATCH 074/122] refactor(native-chat): keep the changed files inside their lint limits The legacy-marker lookup is a table, not a non-exhaustive switch; the preamble tests read the error without a cast; and the Codex history refusal goes through a named constructor so its file stays under the line limit. --- .../codex/codex-structured-session-acquire.ts | 6 ++-- .../structured-agent-session-adapter.ts | 9 +++++ ...stration-structured-worker-session.test.ts | 14 ++++---- src/shared/agent-session-failure-words.ts | 36 ++++++++----------- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index d2abc403324..b7262ea6fee 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -203,10 +203,8 @@ export async function acquireCodexStructuredSession(input: { primaryThreadId = opened.threadId const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) if (restoreAdmission && !restoreAdmission.accepted) { - throw new AgentSessionAcquisitionRefusal( - 'Codex thread history exceeds the bounded restore queue; history was not partially imported.', - 'agent_session_operation_invalid', - 'historyTooLarge' + throw AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' ) } const process = await spawnIdentity.read(connection.pid) 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 00169866543..a8455b907fa 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 @@ -52,6 +52,15 @@ export class AgentSessionAcquisitionRefusal extends Error { super(message) this.name = 'AgentSessionAcquisitionRefusal' } + + /** The conversation's history is more than this host can restore. */ + static historyTooLarge(message: string): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal( + message, + 'agent_session_operation_invalid', + 'historyTooLarge' + ) + } } export class AgentSessionPromptUnavailableError extends Error { 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 6b9e9bdafcc..a88ed7fd80f 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 @@ -323,15 +323,17 @@ describe('structured worker dispatch preamble', () => { reason: 'The provider stopped before this message was sent.' }) ).catch((thrown: unknown) => thrown) - expect((sentence as Error).message).toBe( - 'The dispatch preamble was not delivered: The provider stopped before this message was sent.' - ) + expect(sentence).toMatchObject({ + message: + 'The dispatch preamble was not delivered: The provider stopped before this message was sent.' + }) const marker = await send( hostWithSubmission({ dispatchState: 'unknown', reason: 'provider child exited' }) ).catch((thrown: unknown) => thrown) - expect((marker as Error).message).toBe( - 'The dispatch preamble was submitted but not acknowledged (unknown): provider child exited.' - ) + expect(marker).toMatchObject({ + message: + 'The dispatch preamble was submitted but not acknowledged (unknown): provider child exited.' + }) }) it('reports a refused transport write as undelivered, never as unknown', async () => { diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 0dbe35d351e..4d6c4e3091d 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -179,26 +179,19 @@ export function agentSessionFailureSentence( return sentence(context, fact, surface) } -/** The marker released clients hide, for the rejections that had one before rows carried a fact. +/** The markers released clients hide, for the rejections that had one before rows carried a fact. * A write failure is the bare marker: its error belongs in the log. */ -function legacyRejectionMarker( - fact: AgentSessionFailureFact, - context: AgentSessionFailureWordsContext -): string | undefined { - switch (fact.kind) { - case 'cancelled': - return DISPATCH_REJECTED_CANCELLED - case 'writeFailed': - return DISPATCH_REJECTED_WRITE_FAILED - case 'queueFull': - return context.provider === 'codex' - ? DISPATCH_REJECTED_CODEX_QUEUE_FULL - : context.provider === 'claude' - ? DISPATCH_REJECTED_QUEUE_FULL - : undefined - default: - return undefined - } +const LEGACY_REJECTION_MARKERS: Partial< + Record string | undefined> +> = { + cancelled: () => DISPATCH_REJECTED_CANCELLED, + writeFailed: () => DISPATCH_REJECTED_WRITE_FAILED, + queueFull: ({ provider }) => + provider === 'codex' + ? DISPATCH_REJECTED_CODEX_QUEUE_FULL + : provider === 'claude' + ? DISPATCH_REJECTED_QUEUE_FULL + : undefined } /** The words a status row reporting this fact records. */ @@ -216,8 +209,9 @@ export function agentSessionFailureWords( context: AgentSessionFailureWordsContext & { surface: AgentSessionFailureSurface } ): AgentSessionFailureRowWords | AgentJournalDispatchRejection { const words = - (context.surface === 'rejection' ? legacyRejectionMarker(fact, context) : undefined) ?? - agentSessionFailureSentence(fact, context.surface, context) + (context.surface === 'rejection' + ? LEGACY_REJECTION_MARKERS[fact.kind]?.(context) + : undefined) ?? agentSessionFailureSentence(fact, context.surface, context) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the one constructor of the brand; `words` came from the table or a legacy marker for `fact`. const sentence = words as AgentSessionFailureSentence return context.surface === 'row' 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 075/122] 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 e2f7952cd23..22004de3110 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 076/122] 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 ceaa951e4d7..19d04903987 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 0325cfe80dd..d1cc9408a62 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 35d9f3b14c0..491a2670d0d 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 875f1eb522b..789eb37f45c 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 8c38b02f1aa..7ba1c716769 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 443f1ca7ae0..c6ea9c1f83b 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 00000000000..73ae278f67d --- /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 077/122] 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 22004de3110..10d60791781 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 078/122] 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 9f38c85755f..eef9297e82a 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 079/122] 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 73ae278f67d..ee232450754 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 aac234cd52b..6b50dfa797e 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 93c57473294701d85598389e96bb7079999b831b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:56:19 -0700 Subject: [PATCH 080/122] refactor(native-chat): refusals carry details keyed by their code A refusal named its situation with one flat cause list shared by every code, so nothing stopped a site pairing a code with a situation that code never means, and the loose fields released clients read (fence, revision, resolution, verdict, rewind reason) were written by hand at each emitter. A refusal is now one variant per code with optional details: a reason that code lists plus that code's own facts. refuse(code, details, message) rejects a reason the code does not list at compile time, and it is the one place the loose top-level fields are copied from details, so released clients read exactly what they read before. A site that cannot name its situation uses refuseUnclassified, which carries facts but no reason, the same as an older host; there is no catch-all reason. Thrown refusals put { refusal: { code, details } } in the RPC error's data (wire code and message unchanged). The operation ledger, the restart-resume record and a restart failure's embedded refusal keep details beside the code and read them back against it; a row an unreleased build wrote with a cause parses and reads as naming none. --- .../claude/claude-structured-init-proof.ts | 1 - ...-agent-session-accept-then-deliver.test.ts | 5 +- ...-agent-session-acquisition-options.test.ts | 4 +- .../structured-agent-session-adapter.ts | 19 +- ...structured-agent-session-adopted-import.ts | 18 +- ...structured-agent-session-attach-failure.ts | 2 +- .../structured-agent-session-attach-flow.ts | 4 +- .../structured-agent-session-attach.ts | 35 ++- ...ession-failed-create-owner-verdict.test.ts | 12 +- ...red-agent-session-failed-create-refusal.ts | 46 ++-- ...uctured-agent-session-failure-text.test.ts | 25 +- .../structured-agent-session-failure-text.ts | 11 +- .../structured-agent-session-hold-resume.ts | 40 +-- .../structured-agent-session-holds.ts | 2 +- .../structured-agent-session-host.test.ts | 2 +- ...ctured-agent-session-mutation-admission.ts | 19 +- ...ured-agent-session-operation-settlement.ts | 3 +- .../structured-agent-session-option-error.ts | 2 +- .../structured-agent-session-prompt-state.ts | 33 ++- ...red-agent-session-refusal-details.test.ts} | 107 ++++++-- ...tructured-agent-session-refusal-message.ts | 50 ++-- ...structured-agent-session-replay-outcome.ts | 40 +-- ...ed-agent-session-restart-failure-ledger.ts | 4 +- ...ed-agent-session-restart-ownership.test.ts | 2 +- ...red-agent-session-restart-resume-runner.ts | 10 +- ...ctured-agent-session-restart-resume-set.ts | 6 +- .../structured-agent-session-reveal.ts | 6 +- ...red-agent-session-send-preparation.test.ts | 5 +- ...ructured-agent-session-send-preparation.ts | 6 +- ...ructured-agent-session-thread-goal.test.ts | 2 +- .../structured-agent-session-thread-goal.ts | 6 +- .../structured-agent-session-turns-options.ts | 6 +- .../structured-agent-session-turns-prompt.ts | 9 +- .../structured-agent-session-turns.ts | 6 +- ...ructured-conversation-command-admission.ts | 9 +- ...uctured-conversation-command-controller.ts | 4 +- .../structured-rewind-refusal.ts | 26 +- ...ructured-agent-session-history-adoption.ts | 6 +- ...ent-session-conversation-command-record.ts | 4 +- .../agent-session-lease-transitions.ts | 18 +- .../agent-session-operation-admission.test.ts | 2 +- ...gent-session-provider-handle-transition.ts | 2 +- .../runtime/agent-session-record-options.ts | 2 +- ...ent-session-record-store-tab-table.test.ts | 4 +- .../runtime/agent-session-record-store.ts | 4 +- .../agent-session-recovery-capsule-entries.ts | 20 +- .../agent-session-reservation-admission.ts | 28 +- ...gent-session-surface-release-transition.ts | 2 +- src/main/runtime/agent-session-tab-table.ts | 4 +- ...structured-agent-session-create-support.ts | 4 +- src/main/runtime/rpc/errors.test.ts | 61 +++-- src/main/runtime/rpc/errors.ts | 2 +- .../rpc/methods/agent-launch-replay.test.ts | 2 +- .../structured-agent-session-create.ts | 2 +- .../methods/structured-agent-session-gate.ts | 22 +- ...uctured-agent-session-precommit-refusal.ts | 25 +- .../structured-agent-session-reveal.ts | 13 +- .../rpc/methods/structured-agent-session.ts | 15 +- .../agent-session-failure-words.test.ts | 4 +- src/shared/agent-session-failure-words.ts | 2 +- src/shared/agent-session-failure.test.ts | 15 +- src/shared/agent-session-failure.ts | 13 +- src/shared/agent-session-journal-schemas.ts | 5 + .../agent-session-lease-adjudication.test.ts | 18 +- .../agent-session-lease-adjudication.ts | 50 +++- .../agent-session-mutation-envelope.test.ts | 7 +- src/shared/agent-session-mutation-envelope.ts | 18 +- .../agent-session-operation-ledger.test.ts | 12 +- src/shared/agent-session-operation-ledger.ts | 31 ++- src/shared/agent-session-refusal-details.ts | 259 ++++++++++++++++++ .../agent-session-wire-refusals.test.ts | 218 +++++++++++++++ src/shared/agent-session-wire-refusals.ts | 226 +++++++-------- 72 files changed, 1199 insertions(+), 508 deletions(-) rename src/main/native-chat/agent-session-wire/{structured-agent-session-refusal-cause.test.ts => structured-agent-session-refusal-details.test.ts} (54%) create mode 100644 src/shared/agent-session-refusal-details.ts create mode 100644 src/shared/agent-session-wire-refusals.test.ts diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index 2a2487aad78..0d4af831ea9 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -65,7 +65,6 @@ export function claudeInitializationAuthError( return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' ? new AgentSessionAcquisitionRefusal( 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', - 'agent_session_operation_invalid', 'notSignedIn' ) : null 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 1df4d867489..02d698163cf 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 @@ -342,7 +342,10 @@ describe('a start the chat needed and did not get', () => { text: "Codex couldn't restart. Start a new chat to continue.", failure: { kind: 'restartFailed', - refusal: { code: 'structured_agent_session_unsupported', cause: 'hostUnsupported' } + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } } } ], 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 dbc8111fff0..a2b627cae73 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 @@ -432,7 +432,7 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - cause: 'providerStartFailed', + details: { reason: 'providerStartFailed' }, message: 'model list unavailable' } }) @@ -532,7 +532,7 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - cause: 'providerStartFailed', + details: { reason: 'providerStartFailed' }, message: injected.message } }) 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 a8455b907fa..8249241b4f2 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 @@ -27,12 +27,11 @@ import type { AgentSessionBackgroundTaskState, AgentSessionOptionsResult, AgentSessionSlashCommand, - AgentSessionThreadGoalChange, - AgentSessionWireRefusalCode + AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' import { isAgentSessionWireRefusalCode, - type AgentSessionRefusalCause + type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' @@ -43,11 +42,13 @@ import type { StructuredSessionCompactionResult } from './structured-session-com import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { + readonly code = 'agent_session_operation_invalid' + constructor( message: string, - readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid', - /** The situation, so the chat can say what to do; the message is Orca's log wording. */ - readonly refusalCause?: AgentSessionRefusalCause + /** The situation, so the chat can say what to do; the message is Orca's log wording. Absent, + * the provider refused its own start. */ + readonly reason: AgentSessionRefusalReason<'agent_session_operation_invalid'> = 'providerStartFailed' ) { super(message) this.name = 'AgentSessionAcquisitionRefusal' @@ -55,11 +56,7 @@ export class AgentSessionAcquisitionRefusal extends Error { /** The conversation's history is more than this host can restore. */ static historyTooLarge(message: string): AgentSessionAcquisitionRefusal { - return new AgentSessionAcquisitionRefusal( - message, - 'agent_session_operation_invalid', - 'historyTooLarge' - ) + return new AgentSessionAcquisitionRefusal(message, 'historyTooLarge') } } 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 411a1e1dee9..3eccc4f9457 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 @@ -27,7 +27,7 @@ export async function prepareAdoptedTranscript( ? error.refusal : refuse( 'agent_session_identity_required', - 'transcriptUnreadable', + { reason: 'transcriptUnreadable' }, error instanceof Error ? error.message : String(error) ) } @@ -43,7 +43,9 @@ async function readAdoptedTranscript( return null } if (!adopt.transcriptPath) { - throw agentSessionRefusalError('agent_session_identity_required', 'transcriptNotFound') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, @@ -57,7 +59,9 @@ async function readAdoptedTranscript( throw new Error(prepared.error) } if (prepared.items.length === 0) { - throw agentSessionRefusalError('agent_session_identity_required', 'transcriptUnreadable') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } return prepared.items } @@ -89,7 +93,9 @@ async function applyAdoptedTranscript( return } if (!adopt.transcriptPath) { - throw agentSessionRefusalError('agent_session_identity_required', 'transcriptNotFound') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, @@ -107,6 +113,8 @@ async function applyAdoptedTranscript( // `replaced: false` means the transcript decoded to nothing. The row promised a conversation and // the provider resumed one, so an empty journal here is a disagreement, not an empty chat. if (!imported.replaced) { - throw agentSessionRefusalError('agent_session_identity_required', 'transcriptUnreadable') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } } 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 d4ae5bb3a40..16af00e32ea 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 @@ -35,7 +35,7 @@ export async function settlePostAcquisitionAttachFailure( outcome: { status: 'failed', code: 'agent_session_operation_invalid', - cause: 'hostFault', + details: { reason: 'attachFailed' }, message: cause instanceof Error ? cause.message : String(cause) }, exitProof, 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 5b905062fda..bcc8f53b9d8 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 @@ -83,7 +83,7 @@ export async function performAttach( ok: false, refusal: refuse( 'structured_agent_session_unsupported', - 'hostUnsupported', + { reason: 'hostUnsupported' }, 'This execution host cannot create the requested structured agent session.' ) }) @@ -284,7 +284,7 @@ async function settleUnsupportedReservation( outcome: { status: 'failed', code: 'structured_agent_session_unsupported', - cause: 'hostUnsupported', + details: { reason: 'hostUnsupported' }, message: 'Structured session support changed before the provider could start.' }, exitProof: 'processless', 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 995bbce9e55..10c4866cea3 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 @@ -24,12 +24,14 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, AgentSessionRefusalError, + agentSessionRefusalFromReference, + agentSessionRefusalReference, + isAgentSessionWireRefusalCode, refuse, type AgentSessionMutationEnvelope, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + type AgentSessionRefusalReference, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionFingerprintConflict, @@ -122,7 +124,7 @@ export function admitAttachOrRefuse( ok: false, refusal: refuse( 'agent_session_operation_invalid', - 'requestMalformed', + { reason: 'requestMalformed' }, `A ${params.provider} session requires a ${params.provider} provider handle.` ) } @@ -334,18 +336,21 @@ export function classifyStoreFailure( record: AgentSessionRecord | null = null ): AgentSessionWireRefusal { const rawCode = error instanceof Error ? error.message : String(error) - if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(rawCode)) { + if (!isAgentSessionWireRefusalCode(rawCode)) { throw error } - const code = rawCode as AgentSessionWireRefusalCode - const emitted = error instanceof AgentSessionRefusalError ? error.refusal.cause : undefined + // A refusal error's message is its code, so its details are this code's. + const emitted: AgentSessionRefusalReference = + error instanceof AgentSessionRefusalError + ? agentSessionRefusalReference(error.refusal) + : { code: rawCode } // Why: a latched session is exactly where a bare store code strands the user. - const told = structuredAgentSessionRefusalMessage(code, emitted, record) - const cause = told?.cause ?? emitted - return { - code, - ...(cause ? { cause } : {}), - message: told?.message ?? `The session store refused this call: ${code}.`, - ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) - } + const told = structuredAgentSessionRefusalMessage(emitted, record) + const reference = told?.reference ?? emitted + return agentSessionRefusalFromReference( + reference.code === 'agent_session_checkpoint_stale' && currentFence !== null + ? { code: reference.code, details: { ...reference.details, currentFence } } + : reference, + told?.message ?? `The session store refused this call: ${rawCode}.` + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 35f851a9d2d..37838faa546 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -70,10 +70,11 @@ describe('failed create owner verdict', () => { // The cleanup's release proves the whole tree gone: the common failed start. acquire.mockRejectedValueOnce(new Error(EXIT_REASON)) const first = hostTestAttachParams(null) - // The replay names the same cause as the first answer: the ledger kept it beside the code. + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. const refusal = { code: 'agent_session_operation_invalid', - cause: 'providerStartFailed', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, message: EXIT_REASON, ownerVerdict: 'exited' } @@ -94,10 +95,11 @@ describe('failed create owner verdict', () => { new AgentSessionAcquisitionRootExitObservedError(new Error(EXIT_REASON)) ) const first = hostTestAttachParams(null) - // The replay names the same cause as the first answer: the ledger kept it beside the code. + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. const refusal = { code: 'agent_session_operation_invalid', - cause: 'providerStartFailed', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, message: EXIT_REASON, ownerVerdict: 'exited' } @@ -119,7 +121,7 @@ describe('failed create owner verdict', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - cause: 'providerStartFailed', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, message: 'not signed in', ownerVerdict: 'exited' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index 061f315d014..38f05a42a38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -1,9 +1,9 @@ import { - isAgentSessionRefusalError, refuse, + withAgentSessionRefusalFacts, type AgentSessionAttachResult, type AgentSessionMutationResult, - type AgentSessionRefusalCause, + type AgentSessionRefusalDetailsByCode, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { @@ -29,7 +29,7 @@ export function failedAcquisitionSettlement(error: unknown): { if (error instanceof AgentSessionAcquisitionExitUnprovenError) { const outcome = { code: 'agent_session_ownership_unknown', - cause: 'exitUnproven' as const, + details: { reason: 'ownerUnproven' as const }, message: error.message } return { exitProof: 'unproven', outcome: { status: 'failed', ...outcome } } @@ -40,27 +40,34 @@ export function failedAcquisitionSettlement(error: unknown): { ? 'root-exit-observed' : 'exit-proven' const message = error instanceof Error ? error.message : String(error) - const code = - error instanceof AgentSessionAcquisitionRefusal ? error.code : 'agent_session_operation_invalid' + const details = failedAcquisitionDetails(error) return { exitProof, - outcome: { status: 'failed', code, cause: failedAcquisitionCause(error), message } + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + ...(details ? { details } : {}), + message + } } } -/** The situation a failed acquisition stands for: what the adapter typed, a provider that died - * starting, a store refusal the error carried, or Orca's own fault. */ -function failedAcquisitionCause(error: unknown): AgentSessionRefusalCause { +/** The situation a failed acquisition stands for: what the adapter typed, or a provider that died + * starting. A store refusal names its situation under its own code, which this row does not + * carry, and a failure before the spawn or Orca's own fault names none. */ +function failedAcquisitionDetails( + error: unknown +): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { if (error instanceof AgentSessionAcquisitionRefusal) { - return error.refusalCause ?? 'providerStartFailed' + return { reason: error.reason } } if ( error instanceof AgentSessionAcquisitionRootExitObservedError || error instanceof AgentSessionAcquisitionExitProvenError ) { - return 'providerStartFailed' + return { reason: 'providerStartFailed' } } - return (isAgentSessionRefusalError(error) && error.refusal.cause) || 'hostFault' + return undefined } /** A failed acquisition answered as a refusal on the first call, in the shape its replay takes; @@ -69,10 +76,7 @@ export function failedAcquisitionRefusal( error: unknown ): { ok: false; refusal: AgentSessionWireRefusal } | null { if (error instanceof AgentSessionAcquisitionRefusal) { - return { - ok: false, - refusal: refuse(error.code, failedAcquisitionCause(error), error.message) - } + return { ok: false, refusal: refuse(error.code, { reason: error.reason }, error.message) } } // A proven exit is a settled fact; its message is the provider's own diagnostic. if ( @@ -81,7 +85,11 @@ export function failedAcquisitionRefusal( ) { return { ok: false, - refusal: refuse('agent_session_operation_invalid', 'providerStartFailed', error.message) + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'providerStartFailed' }, + error.message + ) } } return null @@ -96,7 +104,9 @@ export function failedCreateRefusal( return status === 'failed' && record ? { ok: false, - refusal: { ...refusal, ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) } + refusal: withAgentSessionRefusalFacts(refusal, { + ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) + }) } : { ok: false, refusal } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index 3b0adbb9882..aeb26c6265f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -38,7 +38,6 @@ describe('structuredAgentSessionStartFailure', () => { it('keeps a start refusal the adapter typed', () => { const refusal = new AgentSessionAcquisitionRefusal( 'Claude is not signed in for the selected account.', - 'agent_session_operation_invalid', 'notSignedIn' ) expect(structuredAgentSessionStartFailure({ error: refusal }, { agentName: 'Claude' })).toEqual( @@ -55,7 +54,7 @@ describe('structuredAgentSessionStartFailure', () => { { refusal: refuse( 'agent_session_ownership_unknown', - 'ownerUnproven', + { reason: 'ownerUnproven' }, 'Orca cannot prove that process 4242 on host-1 has exited.' ) }, @@ -65,7 +64,10 @@ describe('structuredAgentSessionStartFailure', () => { reason: "Claude couldn't restart.", rejection: { kind: 'restartFailed', - refusal: { code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } }) }) @@ -102,10 +104,11 @@ describe('structuredAgentSessionStartFailure', () => { it('says the provider stopped only when an exit was observed', () => { const exited = structuredAgentSessionStartFailure({ - refusal: { - ...refuse('agent_session_ownership_unknown', 'ownerUnproven', 'probe saw an exit'), - ownerVerdict: 'exited' - } + refusal: refuse( + 'agent_session_ownership_unknown', + { reason: 'ownerUnproven', ownerVerdict: 'exited' }, + 'probe saw an exit' + ) }) expect(exited).toEqual({ reason: 'The provider stopped before it finished starting.', @@ -129,12 +132,16 @@ describe('structuredAgentSessionStartFailure', () => { it('reads a thrown refusal the same as a returned one', () => { const thrown = new AgentSessionRefusalError( - refuse('agent_session_conflict', 'claimConflicted', 'Another process claims this session.') + refuse( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + 'Another process claims this session.' + ) ) expect(thrown.message).toBe('agent_session_conflict') expect(structuredAgentSessionStartFailure({ refusal: thrown.refusal }).rejection).toEqual({ kind: 'restartFailed', - refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 2e11b12c12b..38b83a54a01 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -40,9 +40,9 @@ function providerExitObserved(error: unknown): boolean { export function providerStartupFailureFact(cause?: unknown): AgentSessionFailureFact { if ( cause instanceof AgentSessionAcquisitionRefusal && - (cause.refusalCause === 'notSignedIn' || cause.refusalCause === 'historyTooLarge') + (cause.reason === 'notSignedIn' || cause.reason === 'historyTooLarge') ) { - return agentSessionFailureFact(cause.refusalCause) + return agentSessionFailureFact(cause.reason) } return agentSessionFailureFact( providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', @@ -63,11 +63,12 @@ function startupFailureFromExit( /** What the chat records when the delivery loop could not make the session ready. */ function restartFailureFact(refusal: AgentSessionWireRefusal): AgentSessionFailureFact { - if (refusal.cause === 'notSignedIn' || refusal.cause === 'historyTooLarge') { - return agentSessionFailureFact(refusal.cause) + const reason = refusal.details?.reason + if (reason === 'notSignedIn' || reason === 'historyTooLarge') { + return agentSessionFailureFact(reason) } // A child that died starting reads as any start that died does. - if (refusal.ownerVerdict === 'exited' || refusal.cause === 'providerStartFailed') { + if (refusal.details?.ownerVerdict === 'exited' || reason === 'providerStartFailed') { return agentSessionFailureFact('providerStartFailed') } return agentSessionFailureFact('restartFailed', { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts index d7f09444ac0..1db5c569e65 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts @@ -8,10 +8,11 @@ // readable journal for a live provider child. import { - isAgentSessionRefusalCause, - isAgentSessionWireRefusalCode, + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, refuse, - type AgentSessionRefusalCause + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionAttachResult, @@ -57,14 +58,14 @@ export async function resumeHeldStructuredAgentSession(input: { if (!record) { return refuseResume( 'agent_session_identity_required', - 'recordMissing', + { reason: 'recordMissing' }, 'No structured session exists by that id.' ) } if (!adapterSupportsRecord(context.deps.adapter, record)) { return refuseResume( 'structured_agent_session_unsupported', - 'hostUnsupported', + { reason: 'hostUnsupported' }, 'This execution host cannot resume the requested structured agent session.' ) } @@ -76,18 +77,18 @@ export async function resumeHeldStructuredAgentSession(input: { return record.lease.unreconciled ? refuseResume( 'execution_owner_reconciling', - 'hostReconciling', + { reason: 'hostReconciling' }, 'This host has not yet adjudicated the session lease.' ) : record.lease.claimStatus === 'conflicted' ? refuseResume( 'agent_session_conflict', - 'claimConflicted', + { reason: 'claimConflicted' }, terminalOwnerRefusalMessage(record.lease) ) : refuseResume( 'agent_session_ownership_unknown', - 'notResumable', + { reason: 'notResumable' }, 'The session lease is not one this host may resume.' ) } @@ -126,24 +127,27 @@ function settledResumeRefusal( error: unknown ): StructuredAgentSessionResumeOutcome | null { const outcome = context.deps.store.getOperationRow(callerKey, operationId)?.outcome - if (outcome?.status !== 'failed' || !isAgentSessionWireRefusalCode(outcome.code)) { + const reference = + outcome?.status === 'failed' + ? readAgentSessionRefusalReference({ code: outcome.code, details: outcome.details }) + : undefined + if (outcome?.status !== 'failed' || !reference) { return null } return failedCreateRefusal( - { - code: outcome.code, - ...(isAgentSessionRefusalCause(outcome.cause) ? { cause: outcome.cause } : {}), - message: outcome.message ?? (error instanceof Error ? error.message : String(error)) - }, + agentSessionRefusalFromReference( + reference, + outcome.message ?? (error instanceof Error ? error.message : String(error)) + ), outcome.status, context.deps.store.getRecord(sessionId) ) } -function refuseResume( - code: AgentSessionWireRefusal['code'], - cause: AgentSessionRefusalCause, +function refuseResume( + code: C, + details: NoInfer, message: string ): StructuredAgentSessionResumeOutcome { - return { ok: false, refusal: refuse(code, cause, message) } + return { ok: false, refusal: refuse(code, details, message) } } 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 f4fe34766ba..e386b12add2 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 @@ -129,7 +129,7 @@ export class StructuredAgentSessionHolds { ok: false, refusal: refuse( 'agent_session_ownership_unknown', - 'noProviderChild', + { reason: 'noProviderChild' }, 'The session attached without a provider child to write to.' ) } 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 9031b4841e5..e23d1e5cfc6 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 @@ -143,7 +143,7 @@ describe('attach', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - cause: 'providerStartFailed', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, message: 'agent_session_provider_handle_stale_fence', ownerVerdict: 'exited' } 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 6da1a81d658..6a623428e4f 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 @@ -15,10 +15,11 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -31,11 +32,11 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. -export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { - code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE, - cause: 'sessionNotAttached', - message: 'This host holds no attached session by that id.' -} +export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = refuse( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + { reason: 'sessionNotAttached' }, + 'This host holds no attached session by that id.' +) export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { ok: false 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 5a152fe04f7..e4675bfefb1 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 @@ -41,7 +41,8 @@ export async function runSettledAgentSessionMutation(input: { : { status: 'failed', code: outcome.refusal.code, - ...(outcome.refusal.cause ? { cause: outcome.refusal.cause } : {}), + ...(outcome.refusal.details ? { details: outcome.refusal.details } : {}), + // The row's own field, which builds before details read; copied from the legacy mirror. ...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {}) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts index d7c8803d471..47f67910cf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -3,7 +3,7 @@ export class AgentSessionOptionRejectedError extends Error { constructor( cause: unknown, /** `providerStarting`: nothing is wrong with the value; the session cannot take it yet. */ - readonly refusalCause: 'optionRejected' | 'providerStarting' = 'optionRejected' + readonly refusalReason: 'optionRejected' | 'providerStarting' = 'optionRejected' ) { super(cause instanceof Error ? cause.message : String(cause), { cause }) this.name = 'AgentSessionOptionRejectedError' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 0685838a595..7324e3be9ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -13,7 +13,10 @@ export type PendingPromptValidation = /** The prompt the client named is not one waiting on the user: nothing to answer. */ function promptGone(message: string): PendingPromptValidation { - return { ok: false, refusal: refuse('agent_session_operation_invalid', 'promptGone', message) } + return { + ok: false, + refusal: refuse('agent_session_operation_invalid', { reason: 'promptGone' }, message) + } } export function validatePendingPrompt( @@ -37,25 +40,25 @@ export function validatePendingPrompt( if (item.revision !== input.expectedRevision) { return { ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - cause: 'promptMoved', - message: `Item ${input.itemId} has moved on.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: item.revision, resolution: prompt.resolution }, + `Item ${input.itemId} has moved on.` + ) } } if (prompt.resolution.state !== 'pending') { return { ok: false, - refusal: { - code: 'agent_session_already_resolved', - cause: 'promptAlreadyResolved', - message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_already_resolved', + { + reason: 'promptAlreadyResolved', + currentRevision: item.revision, + resolution: prompt.resolution + }, + `Item ${input.itemId} was already ${prompt.resolution.state}.` + ) } } return { ok: true, item, prompt } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts similarity index 54% rename from src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts index 9bb3b2d290e..9c8a4b98955 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-cause.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -1,4 +1,4 @@ -// A refusal's cause names the situation, so every place that answers for a refusal — the first +// A refusal's details name the situation, so every place that answers for a refusal — the first // reply, a ledger replay, the store fallback copy — must name the same one. import { describe, expect, it } from 'vitest' @@ -27,21 +27,17 @@ function replay(outcome: Parameters[0][ }) } -describe('a ledger replay names the cause its first answer did', () => { +describe('a ledger replay names the details its first answer did', () => { it.each([ - new AgentSessionAcquisitionRefusal( - 'not signed in', - 'agent_session_operation_invalid', - 'notSignedIn' - ), + new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn'), new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')) ])('for a failed create: %s', (error) => { const first = failedAcquisitionRefusal(error) const replayed = replay(failedAcquisitionSettlement(error).outcome) - expect(first?.refusal.cause).toBeDefined() + expect(first?.refusal.details?.reason).toBeDefined() expect(replayed).toMatchObject({ decision: 'refuse', - refusal: { code: first?.refusal.code, cause: first?.refusal.cause } + refusal: { code: first?.refusal.code, details: first?.refusal.details } }) }) @@ -50,26 +46,68 @@ describe('a ledger replay names the cause its first answer did', () => { new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')) ).outcome expect(replay(outcome)).toMatchObject({ - refusal: { code: 'agent_session_ownership_unknown', cause: 'exitUnproven' } + refusal: { code: 'agent_session_ownership_unknown', details: { reason: 'ownerUnproven' } } }) }) - it('reads a row an older host wrote, with no cause, as naming none', () => { + it('replays the facts beside the reason, and mirrors them where released clients read', () => { + const resolution = { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 5 + } + const replayed = replay({ + status: 'failed', + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution } + }) + expect(replayed).toMatchObject({ + refusal: { + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution }, + currentRevision: 3, + resolution + } + }) + }) + + it('reads a row an older host wrote, with no details, as naming none', () => { const replayed = replay({ status: 'failed', code: 'agent_session_conflict' }) expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) - expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('cause') + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + }) + + it('drops the cause an unreleased build wrote, and a reason the code does not list', () => { + for (const row of [ + { status: 'failed' as const, code: 'agent_session_conflict', cause: 'claimConflicted' }, + { + status: 'failed' as const, + code: 'agent_session_conflict', + details: { reason: 'promptGone' as const } + } + ]) { + const replayed = replay(row) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + } }) it('names a code this build cannot place as refused earlier', () => { expect(replay({ status: 'failed', code: 'agent_session_future_code' })).toMatchObject({ - refusal: { code: 'agent_session_operation_invalid', cause: 'operationRefusedEarlier' } + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } }) }) it('names a lost outcome and a lost result', () => { - expect(replay({ status: 'unknown' })).toMatchObject({ refusal: { cause: 'outcomeUnknown' } }) + expect(replay({ status: 'unknown' })).toMatchObject({ + refusal: { details: { reason: 'outcomeUnknown' } } + }) expect(replay({ status: 'succeeded', sessionId: 's' })).toMatchObject({ - refusal: { cause: 'resultLost' } + refusal: { details: { reason: 'resultLost' } } }) }) }) @@ -83,25 +121,25 @@ describe('the store fallback copy', () => { it('words a situation its code would misdescribe by the situation', () => { const refusal = classifyStoreFailure( - agentSessionRefusalError('agent_session_ownership_unknown', 'replaySuperseded'), + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'replaySuperseded' }), null, record ) expect(refusal).toMatchObject({ code: 'agent_session_ownership_unknown', - cause: 'replaySuperseded' + details: { reason: 'replaySuperseded' } }) // Not the latched-owner story: this owner is not in doubt, the replay is just stale. expect(refusal.message).not.toContain('4242') }) - it('keeps the latched-owner story, with its cause, for an owner it cannot prove gone', () => { + it('keeps the latched-owner story, with its reason, for an owner it cannot prove gone', () => { const refusal = classifyStoreFailure( - agentSessionRefusalError('agent_session_ownership_unknown', 'ownerUnproven'), + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }), null, record ) - expect(refusal.cause).toBe('ownerUnproven') + expect(refusal.details?.reason).toBe('ownerUnproven') expect(refusal.message).toContain('4242') }) @@ -109,13 +147,13 @@ describe('the store fallback copy', () => { // Unreadable covers a damaged record as well as one a newer build wrote. expect( classifyStoreFailure( - agentSessionRefusalError('execution_owner_reconciling', 'recordUnreadable'), + agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }), null, null ) ).toEqual({ code: 'execution_owner_reconciling', - cause: 'recordUnreadable', + details: { reason: 'recordUnreadable' }, message: "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." }) @@ -126,10 +164,29 @@ describe('the store fallback copy', () => { classifyStoreFailure(new Error('agent_session_conflict'), null, { ...record, lease: { ...record.lease, claimStatus: 'conflicted' } - }).cause - ).toBe('claimConflicted') + }).details + ).toEqual({ reason: 'claimConflicted' }) expect( classifyStoreFailure(new Error('agent_session_conflict'), null, null) - ).not.toHaveProperty('cause') + ).not.toHaveProperty('details') + }) + + it('puts the current fence in the details of a stale checkpoint, and mirrors it', () => { + expect( + classifyStoreFailure( + agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'fenceStale' }), + 7, + null + ) + ).toEqual({ + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale', currentFence: 7 }, + currentFence: 7, + message: 'The session store refused this call: agent_session_checkpoint_stale.' + }) + // A bare code names no reason, but the fence is still a fact. + expect( + classifyStoreFailure(new Error('agent_session_checkpoint_stale'), 7, null) + ).toMatchObject({ details: { currentFence: 7 }, currentFence: 7 }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts index 56bf898ab92..7a7a9e289ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -10,8 +10,8 @@ import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { - AgentSessionRefusalCause, - AgentSessionWireRefusalCode + AgentSessionAnyRefusalReason, + AgentSessionRefusalReference } from '../../../shared/agent-session-wire' function ownerDescription(record: AgentSessionRecord): string { @@ -32,7 +32,7 @@ function latchedMessage(record: AgentSessionRecord): string { * A situation whose own words the store's code would get wrong: each of these reaches the chat as * `ownership_unknown` or `conflict`, which by code alone would read as the latched-owner story. */ -const SITUATION_MESSAGES: Partial> = { +const SITUATION_MESSAGES: Partial> = { replaySuperseded: 'A newer start of this chat replaced this one. Try again.', leaseMoved: 'This chat changed hands while Orca was starting it. Try again.', spawnIdentityMismatch: @@ -49,42 +49,42 @@ const SITUATION_MESSAGES: Partial> = { } /** - * The words and the situation for a store refusal. `cause` is what the emitter named, when it named - * one; the latched branches name their own. Null when neither has a story to tell, and the caller - * keeps its own wording. + * The words and the situation for a store refusal. The reason is what the emitter named, when it + * named one; the latched branches name their own. Null when neither has a story to tell, and the + * caller keeps its own wording. */ export function structuredAgentSessionRefusalMessage( - code: AgentSessionWireRefusalCode, - cause: AgentSessionRefusalCause | undefined, + emitted: AgentSessionRefusalReference, record: AgentSessionRecord | null -): { message: string; cause: AgentSessionRefusalCause } | null { - const situational = cause ? SITUATION_MESSAGES[cause] : undefined - if (cause && situational) { - return { message: situational, cause } +): { message: string; reference: AgentSessionRefusalReference } | null { + const reason = emitted.details?.reason + const situational = reason ? SITUATION_MESSAGES[reason] : undefined + if (situational) { + return { message: situational, reference: emitted } } if (!record) { return null } + const { code } = emitted if (code === 'agent_session_ownership_unknown' || code === 'agent_session_conflict') { - return latchedRefusal(record, cause) + const message = latchedMessage(record) + if (record.lease.claimStatus === 'conflicted') { + return { message, reference: { code, details: { reason: 'claimConflicted' } } } + } + return { + message, + reference: + code === 'agent_session_conflict' && reason === 'ownerAlive' + ? { code, details: { reason: 'ownerAlive' } } + : { code, details: { reason: 'ownerUnproven' } } + } } if (code === 'execution_owner_reconciling') { return { message: 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.', - cause: 'hostReconciling' + reference: { code, details: { reason: 'hostReconciling' } } } } return null } - -function latchedRefusal( - record: AgentSessionRecord, - cause: AgentSessionRefusalCause | undefined -): { message: string; cause: AgentSessionRefusalCause } { - const message = latchedMessage(record) - if (record.lease.claimStatus === 'conflicted') { - return { message, cause: 'claimConflicted' } - } - return { message, cause: cause === 'ownerAlive' ? 'ownerAlive' : 'ownerUnproven' } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index 0903a06fb36..00fa33a30fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -1,9 +1,10 @@ import { rewindRefusal } from './structured-rewind-refusal' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import { - isAgentSessionRefusalCause, - isAgentSessionWireRefusalCode, + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, refuse, + type AgentSessionRefusalReference, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' @@ -24,24 +25,12 @@ export function resolveAgentSessionReplayOutcome(input: { if (outcome.rewindReason) { return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal } } - const code = isAgentSessionWireRefusalCode(outcome.code) - ? outcome.code - : 'agent_session_operation_invalid' - // The recorded situation, so a replay says what the first answer said; a code this build does - // not know was refused for a reason it cannot name. - const cause = - code !== outcome.code - ? 'operationRefusedEarlier' - : isAgentSessionRefusalCause(outcome.cause) - ? outcome.cause - : undefined return { decision: 'refuse', - refusal: { - code, - ...(cause ? { cause } : {}), - message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` - } + refusal: agentSessionRefusalFromReference( + recordedRefusal(outcome.code, outcome.details), + outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` + ) } } if (outcome.status === 'unknown') { @@ -56,7 +45,7 @@ export function resolveAgentSessionReplayOutcome(input: { decision: 'refuse', refusal: refuse( 'agent_session_operation_unknown', - 'outcomeUnknown', + { reason: 'outcomeUnknown' }, `The outcome of operation ${operationId} is unknown; it was not run again.` ) } @@ -73,9 +62,20 @@ export function resolveAgentSessionReplayOutcome(input: { decision: 'refuse', refusal: refuse( 'agent_session_operation_unknown', - 'resultLost', + { reason: 'resultLost' }, `Operation ${operationId} succeeded, but its result is no longer reconstructable.` ) } : { decision: 'rerun' } } + +/** The refusal a failed row recorded, so a replay says what the first answer said. A code this + * build does not know was refused for a reason it cannot name. */ +function recordedRefusal(code: string, details: unknown): AgentSessionRefusalReference { + return ( + readAgentSessionRefusalReference({ code, details }) ?? { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 41b6085a721..e69e5c344ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -184,7 +184,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failedAt: failure.failedAt, outcome: failure.outcome, reason: failure.reason, - ...(failure.cause ? { cause: failure.cause } : {}), + ...(failure.details ? { details: failure.details } : {}), retryable: deps.retryable(failure.marker) } ] @@ -256,7 +256,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { reason: resumed ? action.failureReason(outcome.sessionId) : (outcome.reason ?? 'agent_session_resume_refused'), - ...(!resumed && outcome.cause ? { cause: outcome.cause } : {}), + ...(!resumed && outcome.details ? { details: outcome.details } : {}), latestPrompt: promptBySession.get(outcome.sessionId) ?? '', latestUserItemId: action.attempts.latestUserItemId(outcome.sessionId) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 5ab80926b35..4128261b028 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 @@ -738,7 +738,7 @@ it('files a restart refused by a conflicted claim under its code, with its cause await vi.waitFor(async () => expect(await host.restartResume.listFailures()).toMatchObject([ - { reason: 'agent_session_conflict', cause: 'claimConflicted' } + { reason: 'agent_session_conflict', details: { reason: 'claimConflicted' } } ]) ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 6d4afec454b..f166fca1eb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -11,7 +11,7 @@ import { isAgentSessionRefusalError, - type AgentSessionRefusalCause + type AgentSessionAnyRefusalDetails } from '../../../shared/agent-session-wire-refusals' import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' @@ -31,8 +31,8 @@ export type StructuredAgentSessionResumeOutcome = { outcome: 'resumed' | 'refused' /** Refusal code; `agent_session_resume_already_in_progress` names the live owner in `owner`. */ reason?: string - /** A thrown refusal's situation, kept apart so `reason` stays the code readers match. */ - cause?: AgentSessionRefusalCause + /** A thrown refusal's details, kept apart so `reason` stays the code readers match. */ + details?: AgentSessionAnyRefusalDetails owner?: string } @@ -129,12 +129,12 @@ async function resumeOne( } catch (error) { const reason = error instanceof Error ? error.message : String(error) const owner = resumeAdmissionOwner(error) - const cause = isAgentSessionRefusalError(error) ? error.refusal.cause : undefined + const details = isAgentSessionRefusalError(error) ? error.refusal.details : undefined return { sessionId, outcome: 'refused', reason, - ...(cause ? { cause } : {}), + ...(details ? { details } : {}), ...(owner === null ? {} : { owner }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 1293893e632..9247f178d34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -13,7 +13,7 @@ // that are not about work at all: the record still exists and this build supports it, and the // lease is free. -import type { AgentSessionRefusalCause } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionAnyRefusalDetails } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionProviderHandleChainHead, @@ -58,8 +58,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa outcome: AgentSessionResumeFailureOutcome /** The host's or provider's refusal code, verbatim, so it can be quoted in a report. */ reason: string - /** The refusal's situation, when the host named one; absent on older records. */ - cause?: AgentSessionRefusalCause + /** The refusal's details beside its code in `reason`; absent on older records and non-refusals. */ + details?: AgentSessionAnyRefusalDetails /** Whether naming it in an action would run it again: whether it is still an offer. A * continuation the chat already holds, or the user having moved on, makes a retry a no-op no * matter what the reason says. */ 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 4ab17403c69..545c7c941dc 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 @@ -31,10 +31,12 @@ export async function revealStructuredAgentSession( ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { - throw agentSessionRefusalError('agent_session_identity_required', 'recordMissing') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } if (!adapterSupportsRecord(deps.adapter, record)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostUnsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a // contested or reconciling chat still reveals and the hold that follows adjudicates it. Refusing 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 7378cccfaaf..3482a2a1a22 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 @@ -498,7 +498,10 @@ describe('a send with no live owner', () => { reason: "Codex couldn't restart. Start a new chat to continue.", rejection: { kind: 'restartFailed', - refusal: { code: 'structured_agent_session_unsupported', cause: 'hostUnsupported' } + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } } }) }) 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 e1637f8c1ef..b9acac3d812 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 @@ -38,12 +38,12 @@ export function structuredAgentSessionSendBlock( refusal: command.replacementSessionId ? refuse( 'agent_session_operation_invalid', - 'conversationCleared', + { reason: 'conversationCleared' }, 'This conversation has been cleared. Use the current conversation.' ) : refuse( 'agent_session_operation_invalid', - 'conversationCommandUnconfirmed', + { reason: 'conversationCommandUnconfirmed' }, 'The conversation operation is unconfirmed.' ) } @@ -66,7 +66,7 @@ export async function openConversationForWrite( ok: false, refusal: refuse( 'agent_session_journal_unreadable', - 'journalUnreadable', + { reason: 'journalUnreadable' }, `The conversation could not be opened: ${ error instanceof Error ? error.message : String(error) }` diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index d8a7390e6c6..f490eb2e9cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -127,7 +127,7 @@ describe('performThreadGoalChange', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - cause: 'providerRejected', + details: { reason: 'providerRejected' }, message: 'goals feature is disabled' } }) 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 b98996606ff..639daf6ab30 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 @@ -1,7 +1,7 @@ // `agentSession.threadGoal`: change the provider thread's goal through the same // admission, ledger and journal path every other session mutation takes. -import { refuse, type AgentSessionRefusalCause } from '../../../shared/agent-session-wire-refusals' +import { refuse, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' import type { AgentJournalItemIdentity, AgentJournalThreadGoal @@ -15,10 +15,10 @@ import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' function refused( - cause: AgentSessionRefusalCause, + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, message: string ): TurnOutcome { - return { ok: false, refusal: refuse('agent_session_operation_invalid', cause, message) } + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** Keyed by the operation, so a replayed set upserts its one objective row. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index eaa1f896b7d..9230e56cbe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -17,7 +17,11 @@ export async function performSetOption( if (isAgentSessionOptionRejectedError(error)) { return { ok: false, - refusal: refuse('agent_session_operation_invalid', error.refusalCause, error.message) + refusal: refuse( + 'agent_session_operation_invalid', + { reason: error.refusalReason }, + error.message + ) } } throw error 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 35d557bde56..f10b64be1db 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 @@ -17,7 +17,7 @@ import type { import { refuse, type AgentSessionPromptResult, - type AgentSessionRefusalCause + type AgentSessionRefusalReason } from '../../../shared/agent-session-wire' import { AgentSessionPromptAnswerRejectedError, @@ -35,8 +35,11 @@ export type AgentSessionPromptRequest = { answers?: AgentSessionQuestionAnswer[] } -function invalid(cause: AgentSessionRefusalCause, message: string): TurnOutcome { - return { ok: false, refusal: refuse('agent_session_operation_invalid', cause, message) } +function invalid( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** The one place a client's choice is read; an answer an older client packed into `optionId` is unpacked here, once. */ 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 0dc9b8318b8..2b755742f8d 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 @@ -16,7 +16,7 @@ import type { import { refuse, type AgentSessionCancelResult, - type AgentSessionRefusalCause, + type AgentSessionRefusalReason, type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' @@ -58,10 +58,10 @@ export type TurnOutcome = | { ok: false; refusal: AgentSessionWireRefusal } function invalid( - cause: AgentSessionRefusalCause, + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, message: string ): { ok: false; refusal: AgentSessionWireRefusal } { - return { ok: false, refusal: refuse('agent_session_operation_invalid', cause, message) } + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` 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 3722731aab6..cecdbbf0b13 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 @@ -3,12 +3,15 @@ import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-a import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { refuse, - type AgentSessionRefusalCause, + type AgentSessionRefusalReason, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' -function blocked(cause: AgentSessionRefusalCause, message: string): AgentSessionWireRefusal { - return refuse('agent_session_operation_invalid', cause, message) +function blocked( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): AgentSessionWireRefusal { + return refuse('agent_session_operation_invalid', { reason }, message) } export function conversationCommandBlocked( diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index d33716ba357..662ec70e8dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -23,7 +23,7 @@ export class StructuredConversationCommandController { ok: false, refusal: refuse( 'agent_session_operation_invalid', - 'conversationCommandInFlight', + { reason: 'conversationCommandInFlight' }, 'Wait for the conversation operation to finish.' ) }) @@ -37,7 +37,7 @@ export class StructuredConversationCommandController { ok: false as const, refusal: refuse( 'agent_session_operation_invalid', - 'conversationCommandInFlight', + { reason: 'conversationCommandInFlight' }, 'Wait for the conversation operation to finish.' ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index 8ca3c39f0c6..af309b13f40 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -2,24 +2,28 @@ import { AGENT_SESSION_REWIND_REASONS, type AgentSessionRewindReason } from '../../../shared/agent-session-rewind' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export function rewindRefusal(reason: AgentSessionRewindReason): { ok: false refusal: AgentSessionWireRefusal } { - const knownReason = + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown' + const message = `agent_session_rewind:${rewindReason}` return { ok: false, - refusal: { - code: - knownReason === 'outcome-unknown' - ? 'agent_session_operation_unknown' - : 'agent_session_operation_invalid', - cause: knownReason === 'outcome-unknown' ? 'rewindUnconfirmed' : 'rewindRefused', - message: `agent_session_rewind:${knownReason}`, - rewindReason: knownReason - } + refusal: + rewindReason === 'outcome-unknown' + ? refuse( + 'agent_session_operation_unknown', + { reason: 'rewindUnconfirmed', rewindReason }, + message + ) + : refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason }, + message + ) } } diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index 5147eb8a94d..e64e8f799d8 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -111,7 +111,7 @@ export function structuredAdoptionConflictError( agentSessionLeaseAdmitsWriter(ownership.lease) ? 'agent_session_conflict' : 'agent_session_ownership_unknown', - 'conversationHeldElsewhere' + { reason: 'conversationHeldElsewhere' } ) } @@ -154,5 +154,7 @@ export async function resolveStructuredAgentSessionAdoption(input: { } // Refuse rather than fall back to the default home. Resuming under a home that does not hold the // conversation is how a "resume" silently becomes a blank chat wearing the old chat's name. - throw agentSessionRefusalError('agent_session_identity_required', 'transcriptNotFound') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 4ac6ca3fa60..297b9dd34e7 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -11,7 +11,7 @@ export function commitConversationCommandRecord( ): void { const record = state.records.get(sessionId) if (!record || record.lease.runtimeFence !== fence) { - throw agentSessionRefusalError('agent_session_checkpoint_stale', 'leaseMoved') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } state.records.set(sessionId, { ...record, conversationCommand: command }) if ( @@ -20,7 +20,7 @@ export function commitConversationCommandRecord( command.replacementSessionId ) { if (!state.records.has(command.replacementSessionId)) { - throw agentSessionRefusalError('agent_session_identity_required', 'recordMissing') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } state.sessionTabs ??= new AgentSessionTabTable() state.sessionTabs.move(sessionId, command.replacementSessionId) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 296e1662054..41e8d300e1e 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -42,10 +42,10 @@ export function withLease( export function assertFence(lease: AgentSessionLease, fence: number): void { if (lease.runtimeFence !== fence) { - throw agentSessionRefusalError('agent_session_checkpoint_stale', 'leaseMoved') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } if (lease.unreconciled) { - throw agentSessionRefusalError('execution_owner_reconciling', 'hostReconciling') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) } } @@ -67,7 +67,7 @@ export function reserveAgentSessionOwner(args: { probe: args.probe }) if (decision.decision === 'refused') { - throw agentSessionRefusalError(decision.code, decision.cause) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision === 'retry-reservation') { return { record, disposition: 'retry-reservation' } @@ -107,11 +107,15 @@ export function commitAgentSessionProcessIdentity( const { record } = args assertFence(record.lease, args.fence) if (record.lease.claimStatus !== 'reserved' || record.lease.ownerProcess !== null) { - throw agentSessionRefusalError('agent_session_ownership_unknown', 'spawnIdentityMismatch') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (record.lease.reservedSpawnToken !== args.process.spawnToken) { // Why: a child that cannot echo the reserved token is not the process Orca started. - throw agentSessionRefusalError('agent_session_ownership_unknown', 'spawnIdentityMismatch') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } return withLease(record, { ...record.lease, @@ -138,7 +142,9 @@ export function proveAgentSessionOwner(args: { record.lease.handoffStage !== 'new-owner-proving' || record.lease.ownerProcess === null ) { - throw agentSessionRefusalError('agent_session_ownership_unknown', 'spawnIdentityMismatch') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (args.link.handle.provider !== record.provider) { throw new Error('agent_session_provider_handle_provider_mismatch') diff --git a/src/main/runtime/agent-session-operation-admission.test.ts b/src/main/runtime/agent-session-operation-admission.test.ts index 3ed9a2f75df..41df221ad12 100644 --- a/src/main/runtime/agent-session-operation-admission.test.ts +++ b/src/main/runtime/agent-session-operation-admission.test.ts @@ -51,7 +51,7 @@ describe('global agent-session operation admission', () => { ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict', - cause: 'operationIdReused' + details: { reason: 'operationIdReused' } }) }) }) diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts index 5e844a8cc1c..57d2ecb172c 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -21,7 +21,7 @@ export function recordAgentSessionProviderHandle(args: { throw new Error('agent_session_provider_handle_invalid') } if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { - throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } const providerHandleChain = appendAgentSessionProviderHandleLink( record.providerHandleChain, diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index 6da38470982..4dac4528017 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -9,7 +9,7 @@ export function replaceAgentSessionRecordOptions( replacement: AgentSessionOptionsReplacement ): AgentSessionRecord { if (record.lease.runtimeFence !== replacement.fence || record.lease.claimStatus !== 'live') { - throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } } diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 838efa41e19..56bcb6debb6 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -113,13 +113,13 @@ describe('chat tab table', () => { (error) => isAgentSessionRefusalError(error) && error.message === 'agent_session_conflict' && - error.refusal.cause === 'tabIdTaken' + error.refusal.details?.reason === 'tabIdTaken' ) await expect(store.setSessionTabVisibility('session-gone', true)).rejects.toSatisfy( (error) => isAgentSessionRefusalError(error) && error.message === 'agent_session_identity_required' && - error.refusal.cause === 'recordMissing' + error.refusal.details?.reason === 'recordMissing' ) }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 85cee667ace..7df509dd810 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -328,8 +328,8 @@ export class AgentSessionRecordStore { const record = this.state.records.get(sessionId) if (!record) { throw this.isSessionUnreadable(sessionId) - ? agentSessionRefusalError('execution_owner_reconciling', 'recordUnreadable') - : agentSessionRefusalError('agent_session_identity_required', 'recordMissing') + ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } const next = apply(record) this.state.records.set(sessionId, next) diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index 6fd2c421000..a5d1d899723 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -5,8 +5,8 @@ // necessarily write, including one written by an older or newer build. import { - isAgentSessionRefusalCause, - type AgentSessionRefusalCause + readAgentSessionRefusalReference, + type AgentSessionAnyRefusalDetails } from '../../shared/agent-session-wire-refusals' import { z } from 'zod' import { @@ -32,7 +32,7 @@ const failureSchema = z.object({ failedAt: z.number().int().nonnegative(), outcome: z.enum(AGENT_SESSION_RESUME_FAILURE_OUTCOMES), reason: z.string().max(MAX_FAILURE_FIELD_LENGTH), - cause: z.string().min(1).optional(), + details: z.unknown().optional(), latestPrompt: z.string().max(MAX_FAILURE_FIELD_LENGTH), latestUserItemId: z.string().max(MAX_FAILURE_FIELD_LENGTH).nullable() }) @@ -54,8 +54,9 @@ export type AgentSessionResumeFailureRecord = { outcome: AgentSessionResumeFailureOutcome /** The refusal code, as it always was; the renderer's guidance keys on it. */ reason: string - /** The refusal's situation beside the code; absent on older records and non-refusals. */ - cause?: AgentSessionRefusalCause + /** The refusal's details beside the code; absent on older records and non-refusals. A record + * an unreleased build wrote with a `cause` instead reads as having none. */ + details?: AgentSessionAnyRefusalDetails /** The prompt the offer quoted, snapshotted because the session may no longer be readable. */ latestPrompt: string /** The chat's newest user message when this was filed, as the marker records it at teardown. A @@ -120,8 +121,13 @@ function parseFailures(value: unknown): AgentSessionResumeFailureRecord[] { if (!parsed.success || !marker) { return [] } - const { cause, ...rest } = parsed.data - return [{ ...rest, marker, ...(isAgentSessionRefusalCause(cause) ? { cause } : {}) }] + const { details: stored, ...rest } = parsed.data + // `reason` is the refusal code, so the details are read against it. + const details = readAgentSessionRefusalReference({ + code: rest.reason, + details: stored + })?.details + return [{ ...rest, marker, ...(details ? { details } : {}) }] }) } diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 68aa66964a9..d86383ddae7 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -113,7 +113,7 @@ export function requireAgentSessionRecordForReplay( if (!record) { // Why: the recorded effect is no longer reconstructable, and re-running it would be a second // spawn rather than a replay. - throw agentSessionRefusalError('agent_session_ownership_unknown', 'recordMissing') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'recordMissing' }) } return record } @@ -129,11 +129,13 @@ export function admitPendingAgentSessionReservationReplay( probe: request.probe }) if (decision.decision === 'refused') { - throw agentSessionRefusalError(decision.code, decision.cause) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision !== 'retry-reservation') { // A replay may continue only its still-present reservation. - throw agentSessionRefusalError('agent_session_ownership_unknown', 'replaySuperseded') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'replaySuperseded' + }) } return record } @@ -171,10 +173,10 @@ export function applyAgentSessionReservation( const existing = state.records.get(request.sessionId) if (!existing) { if (state.unreadableRecords.has(request.sessionId)) { - throw agentSessionRefusalError('execution_owner_reconciling', 'recordUnreadable') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) } if (request.expectedFence !== null) { - throw agentSessionRefusalError('agent_session_checkpoint_stale', 'recordMissing') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } assertReservedTabUnheld(state, request) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } @@ -186,7 +188,7 @@ export function applyAgentSessionReservation( existing.accountHome.path !== request.accountHome.path ) { // Why: location, provider, and account are the session identity; changing one is a fork. - throw agentSessionRefusalError('agent_session_conflict', 'identityMismatch') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) } // A create may take over only a record that never bound a conversation and whose last // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. @@ -195,7 +197,7 @@ export function applyAgentSessionReservation( !request.adoptedHandleLink && agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' if (request.expectedFence === null && !recreatable) { - throw agentSessionRefusalError('agent_session_conflict', 'sessionExists') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } assertReservedTabUnheld(state, request) const pinned = { @@ -241,7 +243,9 @@ function assertAdoptedConversationUnowned( (link) => agentSessionProviderHandleRoot(link.handle) === root ) if (holdsSameConversation) { - throw agentSessionRefusalError('agent_session_conflict', 'conversationHeldElsewhere') + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) } } } @@ -259,11 +263,13 @@ function assertReservedTabUnheld( return } if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw agentSessionRefusalError('agent_session_operation_invalid', 'requestMalformed') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) if (holder !== undefined && holder !== request.sessionId) { - throw agentSessionRefusalError('agent_session_conflict', 'tabIdTaken') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } } @@ -325,7 +331,7 @@ export function commitAgentSessionReservation( if (decision.decision === 'refused') { // An aged-out row proves nothing more: a released reservation runs no effect. if (decision.code !== 'agent_session_operation_expired' || !continued) { - throw agentSessionRefusalError(decision.code, decision.cause) + throw agentSessionRefusalError(decision.code, decision.details) } const row = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) return reserveWithOperationRow(state, continued, row, leaseTtlMs) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index 78106f6c980..7da1e7633ef 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -38,7 +38,7 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { const { record } = args assertFence(record.lease, args.expectedFence) if (!isSurfaceReleasableAgentSessionRecord(record)) { - throw agentSessionRefusalError('agent_session_ownership_unknown', 'leaseMoved') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return withLease(record, { ...record.lease, diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 6e95f7e51ae..6022aac8bf2 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -93,7 +93,7 @@ export class AgentSessionTabTable { private put(tabId: string, sessionId: string): void { const owner = this.sessionByTab.get(tabId) if (owner !== undefined && owner !== sessionId) { - throw agentSessionRefusalError('agent_session_conflict', 'tabIdTaken') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } this.hide(sessionId) this.sessionByTab.set(tabId, sessionId) @@ -121,7 +121,7 @@ export function setAgentSessionTabVisibility( tabId?: string ): void { if (visible && !state.records.has(sessionId)) { - throw agentSessionRefusalError('agent_session_identity_required', 'recordMissing') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } state.sessionTabs ??= new AgentSessionTabTable() if (visible) { diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index b7d471e71b7..ce2cf31b9ce 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -157,7 +157,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { - throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostUnsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const settings = this.requireStore().getSettings() const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index ea4575fa8c5..624557c1f7f 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -17,7 +17,8 @@ import { import { OrchestrationError } from '../orchestration/orchestration-error' import { AgentSessionRefusalError, - agentSessionRefusalError + agentSessionRefusalError, + refuseUnclassified } from '../../../shared/agent-session-wire-refusals' class LineageError extends Error { @@ -308,27 +309,45 @@ describe('thrown agent-session refusals', () => { // Released clients classify a thrown refusal by its wire code and message; both must read // exactly as the bare `Error(code)` this replaced. it.each([ - ['agent_session_ownership_unknown', 'agent_session_ownership_unknown'], - ['structured_agent_session_unsupported', 'runtime_error'], - ['agent_session_journal_unreadable', 'runtime_error'] - ] as const)('keeps %s on the wire as it was, and adds its cause in data', (code, wire) => { - const before = mapRuntimeError('req_1', meta, new Error(code)) - const after = mapRuntimeError('req_1', meta, agentSessionRefusalError(code, 'hostDisabled')) - expect(after.error.code).toBe(before.error.code) - expect(after.error.code).toBe(wire) - expect(after.error.message).toBe(before.error.message) - expect(after.error.message).toBe(code) - expect(after.error.data).toEqual({ refusal: { code, cause: 'hostDisabled' } }) - }) + [ + 'agent_session_ownership_unknown', + 'agent_session_ownership_unknown', + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'noLiveOwner' }) + ], + [ + 'structured_agent_session_unsupported', + 'runtime_error', + agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostDisabled' }) + ], + [ + 'agent_session_checkpoint_stale', + 'agent_session_checkpoint_stale', + agentSessionRefusalError('agent_session_checkpoint_stale', { + reason: 'fenceStale', + currentFence: 4 + }) + ] + ] as const)( + 'keeps %s on the wire as it was, and adds its details in data', + (code, wire, error) => { + const before = mapRuntimeError('req_1', meta, new Error(code)) + const after = mapRuntimeError('req_1', meta, error) + expect(after.error.code).toBe(before.error.code) + expect(after.error.code).toBe(wire) + expect(after.error.message).toBe(before.error.message) + expect(after.error.message).toBe(code) + expect(after.error.data).toEqual({ refusal: { code, details: error.refusal.details } }) + expect(error.refusal.details?.reason).toBeDefined() + } + ) - it('carries no cause in data when the refusal named none', () => { + it('carries no details in data when the refusal named none', () => { const response = mapRuntimeError( 'req_1', meta, - new AgentSessionRefusalError({ - code: 'agent_session_conflict', - message: 'Another process claims this session.' - }) + new AgentSessionRefusalError( + refuseUnclassified('agent_session_conflict', 'Another process claims this session.') + ) ) expect(response.error).toEqual({ code: 'agent_session_conflict', @@ -338,8 +357,8 @@ describe('thrown agent-session refusals', () => { }) it('exposes no code property another passthrough could claim', () => { - expect('code' in agentSessionRefusalError('agent_session_conflict', 'claimConflicted')).toBe( - false - ) + expect( + 'code' in agentSessionRefusalError('agent_session_conflict', { reason: 'claimConflicted' }) + ).toBe(false) }) }) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 0629e81ca63..6d122c02d45 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -241,7 +241,7 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow /** * A thrown agent-session refusal, mapped before any `'code' in error` passthrough so no other * subsystem's code set can claim it. Wire code and message are exactly what the bare `Error(code)` - * it replaced produced — released clients classify both — and the refusal's cause rides only in + * it replaced produced — released clients classify both — and the refusal's details ride only in * `data`, which they ignore. */ function agentSessionRefusalErrorResponse( diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 9d0c40c3735..336cd4c212b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -586,7 +586,7 @@ describe('the inner attach reserves under its own id', () => { expect(forwarded).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict', - cause: 'operationIdReused' + details: { reason: 'operationIdReused' } }) const derived = deriveAgentLaunchChildOperationId(OPERATION_ID) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index b9a33634f69..0c3b7b08713 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -147,7 +147,7 @@ export async function commitStructuredAgentSessionCreate(args: { ok: false, refusal: refuse( 'agent_session_operation_unknown', - 'tabUnconfirmed', + { reason: 'tabUnconfirmed' }, 'The chat may have been created, but its tab could not be confirmed.' ) } 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 8a4bb08fc53..59d78827b26 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -28,10 +28,9 @@ export function supportsStructuredSessions(ctx: RpcContext): boolean { export function requireStructuredCapability(ctx: RpcContext): void { if (!supportsStructuredSessions(ctx)) { - throw agentSessionRefusalError( - 'structured_agent_session_unsupported', - 'clientCapabilityMissing' - ) + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } } @@ -39,7 +38,9 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo requireStructuredCapability(ctx) const host = getStructuredAgentSessionHost() if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostDisabled') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } @@ -68,14 +69,15 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo */ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { if (!supportsStructuredAgentSessionCapability(ctx)) { - throw agentSessionRefusalError( - 'structured_agent_session_unsupported', - 'clientCapabilityMissing' - ) + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } const host = getStructuredAgentSessionHost() if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostDisabled') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts index b538d40a4a2..4038fe9e40d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts @@ -12,7 +12,9 @@ import { AGENT_SESSION_WIRE_REFUSAL_CODES, + agentSessionRefusalFromReference, isAgentSessionRefusalError, + refuseUnclassified, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' @@ -41,24 +43,25 @@ function wireRefusalCode(error: unknown): AgentSessionWireRefusalCode | null { return null } +const PRECOMMIT_REFUSAL_MESSAGE = 'Orca cannot open a structured agent chat for this workspace.' + function precommitRefusal(error: unknown): AgentSessionWireRefusal { + // A refusal the host raised keeps its situation; a bare code names none. + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalFromReference(error.refusal, PRECOMMIT_REFUSAL_MESSAGE) + } const code = wireRefusalCode(error) if (code) { - return { - code, - cause: (isAgentSessionRefusalError(error) && error.refusal.cause) || 'hostUnsupported', - message: 'Orca cannot open a structured agent chat for this workspace.' - } + return refuseUnclassified(code, PRECOMMIT_REFUSAL_MESSAGE) } const message = error instanceof Error ? error.message : String(error) // A code-less failure here is often a defect, not a policy answer; the refusal keeps the user - // moving, the log keeps the cause findable. + // moving, the log keeps the cause findable. Nothing names its situation, so it carries no reason. console.warn('[agent-session] create refused before it committed anything', error) - return { - code: UNCODED_PRECOMMIT_REFUSAL_CODE, - cause: 'hostFault', - message: `Orca could not prepare a structured agent chat for this workspace: ${message}` - } + return refuseUnclassified( + UNCODED_PRECOMMIT_REFUSAL_CODE, + `Orca could not prepare a structured agent chat for this workspace: ${message}` + ) } /** diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 705ffdfbf30..903f8d440af 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -10,6 +10,7 @@ // only a session id cannot aim the publication somewhere else. import { + agentSessionRefusalFromReference, isAgentSessionRefusalError, isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' @@ -40,16 +41,14 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ if (!isAgentSessionWireRefusalCode(code)) { throw error } - return refuseAgentSessionMutation({ - code, - ...(isAgentSessionRefusalError(error) && error.refusal.cause - ? { cause: error.refusal.cause } - : {}), - message: + return refuseAgentSessionMutation( + agentSessionRefusalFromReference( + isAgentSessionRefusalError(error) ? error.refusal : { code }, code === 'structured_agent_session_unsupported' ? 'This host cannot open that chat.' : 'This chat is no longer on this host.' - }) + ) + ) } await ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: revealed.workspaceId, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index b25c6d9ab94..d23c3e21932 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -76,7 +76,9 @@ async function resolveClientSuppliedAttach(params: z.infer, await ensureHostInstalled(ctx) const host = requireHost(ctx) if (!host.supportsCreate(params.location, params.agent)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', 'hostUnsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { @@ -131,10 +133,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CreateSupportParams, handler: async (params, ctx) => { if (!supportsStructuredSessions(ctx)) { - throw agentSessionRefusalError( - 'structured_agent_session_unsupported', - 'clientCapabilityMissing' - ) + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) } @@ -145,7 +146,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) if (params.envelope.expectedRuntimeFence !== null) { - throw agentSessionRefusalError('agent_session_operation_invalid', 'requestMalformed') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so // a client can tell "nothing was created" from "the outcome is unknown". diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 5495c5eb21d..d229efa94ae 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -39,7 +39,7 @@ function factsFor(kind: AgentSessionFailureKind): AgentSessionFailureFact[] { kind, // A log detail and a refusal ride with the fact; neither may reach the sentence. detail: { text: 'Error: ENOENT /Users/me/.claude/x agent_session_conflict', audience: 'log' }, - refusal: { code: 'agent_session_identity_required', cause: 'claimConflicted' } + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } } ] if (kind === 'attachmentInvalid') { @@ -113,7 +113,7 @@ describe('the words written beside a failure fact', () => { agentSessionFailureSentence( { kind: 'restartFailed', - refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } }, 'row', { agentName: 'Codex' } diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 4d6c4e3091d..06808671c4f 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -97,7 +97,7 @@ function couldNot(verb: string): Sentence { return ({ agentName }, fact) => { const failed = `${agentName ?? 'The agent'} couldn't ${verb}.` // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. - if (fact.refusal?.cause === 'claimConflicted') { + if (fact.refusal?.details?.reason === 'claimConflicted') { return `${failed} This chat is still open in a terminal agent. Quit that agent to continue the chat here.` } const code = fact.refusal?.code diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index 48b5161128c..9dde30aff4e 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -42,23 +42,32 @@ describe('reading a failure fact', () => { expect( readAgentSessionFailureFact({ kind: 'restartFailed', - refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' }, + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }, detail: { text: 'x', audience: 'person' } }) ).toEqual({ kind: 'restartFailed', - refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' }, + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }, detail: { text: 'x', audience: 'person' } }) }) + it('reads a row an unreleased build wrote with a cause as a refusal with no details', () => { + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + }) + ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) + }) + it('drops what a newer host wrote that this build cannot place', () => { expect(readAgentSessionFailureFact({ kind: 'futureKind' })).toBeUndefined() expect(readAgentSessionFailureFact(undefined)).toBeUndefined() expect( readAgentSessionFailureFact({ kind: 'restartFailed', - refusal: { code: 'agent_session_conflict', cause: 'futureCause' }, + refusal: { code: 'agent_session_conflict', details: { reason: 'futureReason' } }, detail: { text: 'x', audience: 'future' } }) ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 91c18a5eabb..b809f7dd489 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -7,9 +7,8 @@ // tell a provider's sentence from Orca's. import { - isAgentSessionWireRefusalCode, - type AgentSessionRefusalReference, - isAgentSessionRefusalCause + readAgentSessionRefusalReference, + type AgentSessionRefusalReference } from './agent-session-wire-refusals' /** One per situation with its own honest next step; a new one needs copy before it compiles. */ @@ -147,13 +146,7 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure if (!isRecord(value) || !isAgentSessionFailureKind(value.kind)) { return undefined } - const refusal = - isRecord(value.refusal) && isAgentSessionWireRefusalCode(value.refusal.code) - ? { - code: value.refusal.code, - ...(isAgentSessionRefusalCause(value.refusal.cause) ? { cause: value.refusal.cause } : {}) - } - : undefined + const refusal = readAgentSessionRefusalReference(value.refusal) const attachment = readAttachmentProblem(value.attachment) return agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 3b2caf196b2..82b316d9306 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -18,6 +18,7 @@ import { AgentSessionContextUsageSchema } from './agent-session-context-usage-sc import type { AgentJournalItemBody, AgentJournalMessageItem, + AgentJournalResolution, AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' @@ -319,6 +320,10 @@ export const AgentJournalSubmissionSchema = z.object({ rejection: FailureFact.optional() }) +export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { + return Resolution.safeParse(value).success +} + export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody { return AgentJournalItemBodySchema.safeParse(value).success } diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index 3981ff5078a..602a263e91b 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -94,7 +94,7 @@ describe('acquisition compare-and-swap', () => { ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale', - cause: 'fenceStale' + details: { reason: 'fenceStale' } }) expect(acquire(held, MATCHED)).toEqual({ decision: 'granted', nextFence: 8 }) }) @@ -115,7 +115,7 @@ describe('acquisition compare-and-swap', () => { ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale', - cause: 'fenceStale' + details: { reason: 'fenceStale' } }) }) @@ -125,12 +125,12 @@ describe('acquisition compare-and-swap', () => { expect(acquire(expired, INDETERMINATE)).toEqual({ decision: 'refused', code: 'agent_session_ownership_unknown', - cause: 'ownerUnproven' + details: { reason: 'ownerUnproven' } }) expect(acquire(expired, MATCHED)).toEqual({ decision: 'refused', code: 'agent_session_conflict', - cause: 'ownerAlive' + details: { reason: 'ownerAlive' } }) expect(acquire(expired, { outcome: 'pid-absent' })).toEqual({ decision: 'granted', @@ -142,7 +142,7 @@ describe('acquisition compare-and-swap', () => { expect(acquire(lease({ unreconciled: true }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', code: 'execution_owner_reconciling', - cause: 'hostReconciling' + details: { reason: 'hostReconciling' } }) }) @@ -151,7 +151,7 @@ describe('acquisition compare-and-swap', () => { expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({ decision: 'refused', code: 'agent_session_conflict', - cause: 'claimConflicted' + details: { reason: 'claimConflicted' } }) }) @@ -159,7 +159,7 @@ describe('acquisition compare-and-swap', () => { expect(acquire(lease({ handoffStage: 'recovering' }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', code: 'agent_session_ownership_unknown', - cause: 'ownerUnproven' + details: { reason: 'ownerUnproven' } }) }) @@ -173,7 +173,7 @@ describe('acquisition compare-and-swap', () => { expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict', - cause: 'handoffInFlight' + details: { reason: 'handoffInFlight' } }) expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ decision: 'retry-reservation', @@ -186,7 +186,7 @@ describe('acquisition compare-and-swap', () => { expect(acquire(reserved, INDETERMINATE)).toEqual({ decision: 'refused', code: 'agent_session_ownership_unknown', - cause: 'ownerUnproven' + details: { reason: 'ownerUnproven' } }) expect(acquire(reserved, { outcome: 'reservation-unused' })).toEqual({ decision: 'granted', diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index 9a87506b491..bbe409a37fc 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -17,7 +17,7 @@ import type { } from './agent-session-record' import type { AgentSessionOwnerVerdict, - AgentSessionRefusalCause + AgentSessionRefusalDetailsByCode } from './agent-session-wire-refusals' export type AgentSessionIdentityMatchField = 'process-start-time' | 'spawn-token' @@ -47,7 +47,13 @@ export type AgentSessionAcquisitionDecision = | { decision: 'granted'; nextFence: number } /** The same acquisition operation re-entering its own reservation; no new fence, no new spawn. */ | { decision: 'retry-reservation'; fence: number } - | { decision: 'refused'; code: AgentSessionLeaseRefusalCode; cause: AgentSessionRefusalCause } + | { + [C in AgentSessionLeaseRefusalCode]: { + decision: 'refused' + code: C + details: AgentSessionRefusalDetailsByCode[C] + } + }[AgentSessionLeaseRefusalCode] export type AgentSessionRestartAdjudication = /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ @@ -134,18 +140,34 @@ export function evaluateAgentSessionAcquisition(args: { }): AgentSessionAcquisitionDecision { const { lease, expectedFence, handoffOperationId, probe } = args if (lease.unreconciled) { - return { decision: 'refused', code: 'execution_owner_reconciling', cause: 'hostReconciling' } + return { + decision: 'refused', + code: 'execution_owner_reconciling', + details: { reason: 'hostReconciling' } + } } if (!isAgentSessionFenceCurrent(lease, expectedFence)) { - return { decision: 'refused', code: 'agent_session_checkpoint_stale', cause: 'fenceStale' } + return { + decision: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + } } if (lease.claimStatus === 'conflicted') { // Why: the user's own terminal agent; restart adjudication and recovery retire it once gone. - return { decision: 'refused', code: 'agent_session_conflict', cause: 'claimConflicted' } + return { + decision: 'refused', + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } + } } if (lease.handoffStage === 'recovering') { // Why: no stage expires into an owner; recovery resolution concludes about it first. - return { decision: 'refused', code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } + return { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } if (lease.handoffStage !== null && lease.handoffOperationId !== null) { if (handoffOperationId !== lease.handoffOperationId) { @@ -153,7 +175,7 @@ export function evaluateAgentSessionAcquisition(args: { return { decision: 'refused', code: 'agent_session_operation_conflict', - cause: 'handoffInFlight' + details: { reason: 'handoffInFlight' } } } if ( @@ -170,15 +192,23 @@ export function evaluateAgentSessionAcquisition(args: { // Why: a lapsed deadline means Orca stopped hearing from the owner, not that the child // stopped editing files and spending tokens. return isProvenAliveProbe(probe) - ? { decision: 'refused', code: 'agent_session_conflict', cause: 'ownerAlive' } - : { decision: 'refused', code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } + ? { decision: 'refused', code: 'agent_session_conflict', details: { reason: 'ownerAlive' } } + : { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } if (lease.claimStatus === 'reserved' && probe.outcome !== 'reservation-unused') { // Why: a reservation with no proven process is not a free lease — the crash may have lost // the race with the spawn rather than beaten it. - return { decision: 'refused', code: 'agent_session_ownership_unknown', cause: 'ownerUnproven' } + return { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index 9629f9c215a..028de74935a 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -166,12 +166,15 @@ describe('admitAgentSessionMutation', () => { ledger: { decision: 'refused', code: 'agent_session_operation_expired', - cause: 'operationExpired' + details: { reason: 'operationExpired' } } }) expect(admission).toMatchObject({ decision: 'refused', - refusal: { code: 'agent_session_operation_expired', cause: 'operationExpired' } + refusal: { + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } }) }) }) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 236103065fb..770c184b562 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -67,7 +67,7 @@ export function agentSessionFingerprintConflict( ? null : refuse( 'agent_session_operation_conflict', - 'fingerprintMismatch', + { reason: 'fingerprintMismatch' }, 'The payload does not match the fingerprint the client declared for this operation.' ) } @@ -107,7 +107,7 @@ export function admitAgentSessionMutation(input: { decision: 'refused', refusal: refuse( ledger.code, - ledger.cause, + ledger.details, `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` ) } @@ -134,25 +134,29 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR if (lease.unreconciled) { return refuse( 'execution_owner_reconciling', - 'hostReconciling', + { reason: 'hostReconciling' }, 'This host has not yet adjudicated the session lease.' ) } if (lease.handoffStage !== null) { if (lease.claimStatus === 'conflicted') { - return refuse('agent_session_conflict', 'claimConflicted', terminalOwnerRefusalMessage(lease)) + return refuse( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(lease) + ) } return lease.handoffStage === 'new-owner-proving' - ? refuse('agent_session_conflict', 'chatStarting', 'The chat is still starting.') + ? refuse('agent_session_conflict', { reason: 'chatStarting' }, 'The chat is still starting.') : refuse( 'agent_session_conflict', - 'ownerUnproven', + { reason: 'ownerUnproven' }, "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." ) } return refuse( 'agent_session_ownership_unknown', - 'noLiveOwner', + { reason: 'noLiveOwner' }, 'The session has no live owner to accept writes.' ) } diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts index a85b63f619f..ef10fac8f2b 100644 --- a/src/shared/agent-session-operation-ledger.test.ts +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -65,7 +65,7 @@ describe('operation admission', () => { expect(evaluate(rows, { fingerprint: 'fp-2' })).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict', - cause: 'operationIdReused' + details: { reason: 'operationIdReused' } }) }) @@ -80,7 +80,7 @@ describe('operation admission', () => { expect(evaluate(rows, { operationId: 'not-an-operation-id' })).toEqual({ decision: 'refused', code: 'agent_session_operation_invalid', - cause: 'operationIdInvalid' + details: { reason: 'operationIdInvalid' } }) // Why: a future-dated id would look new again after its own tombstone is collected. expect( @@ -90,7 +90,7 @@ describe('operation admission', () => { ).toEqual({ decision: 'refused', code: 'agent_session_operation_invalid', - cause: 'operationIdInvalid' + details: { reason: 'operationIdInvalid' } }) expect( evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) }) @@ -104,7 +104,7 @@ describe('operation admission', () => { expect(evaluate(rows, { operationId: stale })).toEqual({ decision: 'refused', code: 'agent_session_operation_expired', - cause: 'operationExpired' + details: { reason: 'operationExpired' } }) expect( evaluate(rows, { operationId: operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) }) @@ -118,13 +118,13 @@ describe('operation admission', () => { expect(evaluate(rows, { perClientLimit: 1 })).toEqual({ decision: 'refused', code: 'agent_session_operation_capacity', - cause: 'operationCapacity' + details: { reason: 'operationCapacity' } }) // A different caller is still refused once the global cap is reached. expect(evaluate(rows, { callerKey: 'client-2', globalLimit: 1 })).toEqual({ decision: 'refused', code: 'agent_session_operation_capacity', - cause: 'operationCapacity' + details: { reason: 'operationCapacity' } }) expect(evaluate(rows, { callerKey: 'client-2', perClientLimit: 1 }).decision).toBe('admit') }) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index be4f4473975..90caec22bb7 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -1,4 +1,7 @@ -import type { AgentSessionRefusalCause } from './agent-session-wire-refusals' +import type { + AgentSessionAnyRefusalDetails, + AgentSessionRefusalDetailsByCode +} from './agent-session-refusal-details' import { isAgentSessionRewindResult, type AgentSessionRewindReason, @@ -61,8 +64,9 @@ export type AgentSessionOperationOutcome = code: string message?: string rewindReason?: AgentSessionRewindReason - /** Beside the code, so a replay names the situation the first answer did. */ - cause?: AgentSessionRefusalCause + /** Beside the code, so a replay says what the first answer did. Read back against the code, + * since the code is a string here; a row written before details carries none. */ + details?: AgentSessionAnyRefusalDetails } /** The effect may or may not have happened; replay this answer instead of spawning again. */ | { status: 'unknown' } @@ -87,13 +91,12 @@ export type AgentSessionOperationDecision = | { decision: 'replay'; row: AgentSessionOperationRow } | { decision: 'admit'; row: AgentSessionOperationRow } | { - decision: 'refused' - code: AgentSessionOperationRefusalCode - cause: Extract< - AgentSessionRefusalCause, - 'operationIdInvalid' | 'operationIdReused' | 'operationExpired' | 'operationCapacity' - > - } + [C in AgentSessionOperationRefusalCode]: { + decision: 'refused' + code: C + details: AgentSessionRefusalDetailsByCode[C] + } + }[AgentSessionOperationRefusalCode] /** NUL cannot occur in a caller key or operation id, so no pair can forge another pair's key. */ const OPERATION_KEY_SEPARATOR = '\u0000' @@ -246,7 +249,7 @@ export function evaluateAgentSessionOperation(args: { return { decision: 'refused', code: 'agent_session_operation_invalid', - cause: 'operationIdInvalid' + details: { reason: 'operationIdInvalid' } } } const key = agentSessionOperationKey(callerKey, operationId) @@ -257,7 +260,7 @@ export function evaluateAgentSessionOperation(args: { : { decision: 'refused', code: 'agent_session_operation_conflict', - cause: 'operationIdReused' + details: { reason: 'operationIdReused' } } } if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { @@ -266,7 +269,7 @@ export function evaluateAgentSessionOperation(args: { return { decision: 'refused', code: 'agent_session_operation_expired', - cause: 'operationExpired' + details: { reason: 'operationExpired' } } } const perClientLimit = args.perClientLimit ?? AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT @@ -283,7 +286,7 @@ export function evaluateAgentSessionOperation(args: { return { decision: 'refused', code: 'agent_session_operation_capacity', - cause: 'operationCapacity' + details: { reason: 'operationCapacity' } } } return { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts new file mode 100644 index 00000000000..9c7063b63ea --- /dev/null +++ b/src/shared/agent-session-refusal-details.ts @@ -0,0 +1,259 @@ +// What a refusal says beyond its code: one reason list and one set of facts per code. +// +// A code alone cannot say what to do: `agent_session_operation_invalid` covers a cleared +// conversation (open the current one) and a busy command (wait). Each reason here is one situation +// with its own honest words and next step; a reason two codes share is listed under each. There is +// no catch-all: a site that cannot name its situation sends the code with no reason, the same as an +// older host, and the reader falls back to what it does for the code. + +import type { AgentJournalResolution } from './agent-session-journal-types' +import { isAgentJournalResolution } from './agent-session-journal-schemas' +import { AGENT_SESSION_REWIND_REASONS, type AgentSessionRewindReason } from './agent-session-rewind' +import type { + AgentSessionOwnerVerdict, + AgentSessionWireRefusalCode +} from './agent-session-wire-refusals' + +export const AGENT_SESSION_REFUSAL_REASONS = { + agent_session_operation_invalid: [ + // The request + 'requestMalformed', + 'operationIdInvalid', + 'messageIdReused', + 'operationRefusedEarlier', + 'journalWriteFailed', + // The conversation's state + 'conversationCleared', + 'conversationCommandUnconfirmed', + 'conversationCommandInFlight', + 'handoffInFlight', + 'turnActive', + 'promptPending', + 'backgroundTasksRunning', + 'messagesUnsettled', + 'rewindRefused', + 'rewindUnconfirmed', + // A prompt card or an option + 'promptGone', + 'optionRejected', + // The provider + 'providerStarting', + 'goalsUnsupported', + 'providerRejected', + 'providerStartFailed', + 'notSignedIn', + 'historyTooLarge', + /** The agent started, then Orca could not open the chat's conversation for it. */ + 'attachFailed' + ], + agent_session_ownership_unknown: [ + 'sessionNotAttached', + 'noLiveOwner', + /** Orca cannot prove an agent process it recorded or started has exited. */ + 'ownerUnproven', + 'claimConflicted', + 'recordMissing', + 'replaySuperseded', + 'leaseMoved', + 'spawnIdentityMismatch', + 'notResumable', + 'noProviderChild', + 'conversationHeldElsewhere' + ], + agent_session_conflict: [ + 'chatStarting', + 'ownerUnproven', + 'claimConflicted', + 'ownerAlive', + 'identityMismatch', + 'sessionExists', + 'conversationHeldElsewhere', + 'tabIdTaken' + ], + execution_owner_reconciling: ['hostReconciling', 'recordUnreadable'], + agent_session_checkpoint_stale: ['fenceStale', 'leaseMoved', 'recordMissing'], + agent_session_identity_required: ['recordMissing', 'transcriptNotFound', 'transcriptUnreadable'], + agent_session_operation_conflict: ['fingerprintMismatch', 'operationIdReused', 'handoffInFlight'], + agent_session_operation_expired: ['operationExpired'], + agent_session_operation_capacity: ['operationCapacity'], + agent_session_operation_unknown: [ + 'outcomeUnknown', + 'resultLost', + 'rewindUnconfirmed', + 'tabUnconfirmed' + ], + agent_session_item_revision_stale: ['promptMoved'], + agent_session_already_resolved: ['promptAlreadyResolved'], + agent_session_journal_unreadable: ['journalUnreadable'], + structured_agent_session_unsupported: [ + 'clientCapabilityMissing', + 'hostDisabled', + 'hostUnsupported' + ], + // No host path sends it; older clients still read it. + agent_session_owner_restart_failed: [] +} as const satisfies Record + +export type AgentSessionRefusalReason = + (typeof AGENT_SESSION_REFUSAL_REASONS)[C][number] + +/** A reason under any code, for a reader that keys words on the name alone. */ +export type AgentSessionAnyRefusalReason = AgentSessionRefusalReason + +type RewindFacts = { + /** Which rewind check refused. */ + rewindReason?: AgentSessionRewindReason +} + +type PromptFacts = { + /** The revision the host actually holds. */ + currentRevision?: number + /** The winning answer and who gave it. */ + resolution?: AgentJournalResolution +} + +type NoFacts = Record + +/** The facts a code carries beside its reason. */ +type AgentSessionRefusalFactsByCode = { + agent_session_operation_invalid: RewindFacts + agent_session_operation_unknown: RewindFacts + agent_session_checkpoint_stale: { + /** So the client can retry without another round trip. */ + currentFence?: number + } + agent_session_item_revision_stale: PromptFacts + agent_session_already_resolved: PromptFacts + agent_session_ownership_unknown: NoFacts + agent_session_conflict: NoFacts + execution_owner_reconciling: NoFacts + agent_session_identity_required: NoFacts + agent_session_operation_conflict: NoFacts + agent_session_operation_expired: NoFacts + agent_session_operation_capacity: NoFacts + agent_session_journal_unreadable: NoFacts + structured_agent_session_unsupported: NoFacts + agent_session_owner_restart_failed: NoFacts +} + +/** A durably failed create says whether anything still runs for it, whichever code it failed + * with: `exited` proves nothing does, so a new operation cannot collide with this one. */ +type AgentSessionRefusalCommonFacts = { ownerVerdict?: AgentSessionOwnerVerdict } + +export type AgentSessionRefusalFacts = + AgentSessionRefusalFactsByCode[C] & AgentSessionRefusalCommonFacts + +/** What an emitter must name: a reason its code lists, and that code's facts. */ +export type AgentSessionRefusalDetailsByCode = { + [C in AgentSessionWireRefusalCode]: { + reason: AgentSessionRefusalReason + } & AgentSessionRefusalFacts +} + +/** Details as the wire and a durable record carry them. The reason is absent where the host could + * not name the situation; the whole object is absent from older hosts. */ +export type AgentSessionRefusalDetails = { + reason?: AgentSessionRefusalReason +} & AgentSessionRefusalFacts + +/** Details under whichever code, for a store that keeps them beside a code it reads as a string. */ +export type AgentSessionAnyRefusalDetails = AgentSessionRefusalDetails + +/** The loose top-level fields released clients read; the host writes them only from details. */ +export type AgentSessionLegacyRefusalFields = { + currentFence?: number + currentRevision?: number + resolution?: AgentJournalResolution + ownerVerdict?: AgentSessionOwnerVerdict + rewindReason?: AgentSessionRewindReason +} + +const FACT_KEYS_BY_CODE: Partial< + Record +> = { + agent_session_operation_invalid: ['rewindReason'], + agent_session_operation_unknown: ['rewindReason'], + agent_session_checkpoint_stale: ['currentFence'], + agent_session_item_revision_stale: ['currentRevision', 'resolution'], + agent_session_already_resolved: ['currentRevision', 'resolution'] +} + +const OWNER_VERDICTS: readonly AgentSessionOwnerVerdict[] = ['live', 'unverifiable', 'exited'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readFact( + key: keyof AgentSessionLegacyRefusalFields, + value: unknown +): AgentSessionLegacyRefusalFields { + switch (key) { + case 'currentFence': + return Number.isSafeInteger(value) && typeof value === 'number' ? { currentFence: value } : {} + case 'currentRevision': + return Number.isSafeInteger(value) && typeof value === 'number' + ? { currentRevision: value } + : {} + case 'resolution': + return isAgentJournalResolution(value) ? { resolution: value } : {} + case 'ownerVerdict': { + const ownerVerdict = OWNER_VERDICTS.find((verdict) => verdict === value) + return ownerVerdict ? { ownerVerdict } : {} + } + case 'rewindReason': { + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((reason) => reason === value) + return rewindReason ? { rewindReason } : {} + } + } +} + +export function isAgentSessionRefusalReason( + code: C, + value: unknown +): value is AgentSessionRefusalReason { + const reasons: readonly string[] = AGENT_SESSION_REFUSAL_REASONS[code] + return typeof value === 'string' && reasons.includes(value) +} + +/** + * Details as a reader meets them: from the wire, a ledger row or a stored failure, possibly + * written by another build. Keeps only a reason and facts this code lists, so a reason a newer + * host added reads as none; undefined when nothing is left. + */ +export function readAgentSessionRefusalDetails( + code: C, + value: unknown +): AgentSessionRefusalDetails | undefined { + if (!isRecord(value)) { + return undefined + } + const keys = [...(FACT_KEYS_BY_CODE[code] ?? []), 'ownerVerdict' as const] + const facts = keys.reduce( + (kept, key) => ({ ...kept, ...readFact(key, value[key]) }), + {} + ) + const read = { + ...(isAgentSessionRefusalReason(code, value.reason) ? { reason: value.reason } : {}), + ...facts + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reason was checked against `code`'s list and only the facts `code` lists (plus the verdict every code may carry) were kept. + return Object.keys(read).length > 0 ? (read as AgentSessionRefusalDetails) : undefined +} + +/** The loose fields released clients read, taken from details; the one place they are written. */ +export function agentSessionLegacyRefusalFields( + details: AgentSessionLegacyRefusalFields | undefined +): AgentSessionLegacyRefusalFields { + if (!details) { + return {} + } + const { currentFence, currentRevision, resolution, ownerVerdict, rewindReason } = details + return { + ...(rewindReason !== undefined ? { rewindReason } : {}), + ...(currentFence !== undefined ? { currentFence } : {}), + ...(resolution !== undefined ? { resolution } : {}), + ...(currentRevision !== undefined ? { currentRevision } : {}), + ...(ownerVerdict !== undefined ? { ownerVerdict } : {}) + } +} diff --git a/src/shared/agent-session-wire-refusals.test.ts b/src/shared/agent-session-wire-refusals.test.ts new file mode 100644 index 00000000000..af017a617e7 --- /dev/null +++ b/src/shared/agent-session-wire-refusals.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_REFUSAL_REASONS, + readAgentSessionRefusalDetails +} from './agent-session-refusal-details' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + agentSessionRefusalError, + agentSessionRefusalReference, + readAgentSessionRefusalReference, + refuse, + refuseUnclassified, + withAgentSessionRefusalFacts, + type AgentSessionWireRefusal +} from './agent-session-wire-refusals' +import type { AgentJournalResolution } from './agent-session-journal-types' + +const RESOLUTION: AgentJournalResolution = { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone-1', + resolvedAt: 1_789_000_000_000 +} + +/** The refusal with its new field removed: exactly what a released client reads. */ +function asReleasedClientSeesIt(refusal: AgentSessionWireRefusal): unknown { + const { details: _details, ...rest } = JSON.parse(JSON.stringify(refusal)) + return rest +} + +describe('a refusal names only a reason its code lists', () => { + it('rejects a reason another code lists, at compile time', () => { + // @ts-expect-error `promptGone` is an operation_invalid reason, not a conflict one. + refuse('agent_session_conflict', { reason: 'promptGone' }, 'm') + // @ts-expect-error the thrown form is held to the same list. + agentSessionRefusalError('agent_session_identity_required', { reason: 'claimConflicted' }) + // @ts-expect-error a fact another code carries is not this code's. + refuse('agent_session_conflict', { reason: 'ownerAlive', currentFence: 3 }, 'm') + // @ts-expect-error the unclassified path cannot carry a reason. + refuseUnclassified('agent_session_conflict', 'm', { reason: 'claimConflicted' }) + expect(refuse('agent_session_conflict', { reason: 'ownerAlive' }, 'm').details).toEqual({ + reason: 'ownerAlive' + }) + }) + + it('has no catch-all reason under any code', () => { + for (const code of AGENT_SESSION_WIRE_REFUSAL_CODES) { + for (const reason of AGENT_SESSION_REFUSAL_REASONS[code]) { + expect([code, reason]).not.toEqual([code, expect.stringMatching(/^(other|unknown)$/i)]) + expect(reason).not.toMatch(/fault|generic|misc/i) + } + } + }) +}) + +describe('the loose fields released clients read', () => { + it('are copied from details by refuse, one field at a time', () => { + expect( + refuse('agent_session_checkpoint_stale', { reason: 'fenceStale', currentFence: 4 }, 'm') + ).toMatchObject({ currentFence: 4 }) + expect( + refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: 2, resolution: RESOLUTION }, + 'm' + ) + ).toMatchObject({ currentRevision: 2, resolution: RESOLUTION }) + expect( + refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason: 'busy' }, + 'm' + ) + ).toMatchObject({ rewindReason: 'busy' }) + expect( + refuse( + 'agent_session_ownership_unknown', + { reason: 'ownerUnproven', ownerVerdict: 'exited' }, + 'm' + ) + ).toMatchObject({ ownerVerdict: 'exited' }) + expect( + refuseUnclassified('agent_session_checkpoint_stale', 'm', { currentFence: 9 }) + ).toMatchObject({ currentFence: 9, details: { currentFence: 9 } }) + }) + + it('gain a verdict learned after the refusal, in details and at the top level', () => { + const stamped = withAgentSessionRefusalFacts( + refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'm'), + { ownerVerdict: 'exited' } + ) + expect(stamped).toEqual({ + code: 'agent_session_operation_invalid', + message: 'm', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + ownerVerdict: 'exited' + }) + // A refusal an older row replayed, with no reason, still reaches released clients with it. + expect( + withAgentSessionRefusalFacts(refuseUnclassified('agent_session_conflict', 'm'), { + ownerVerdict: 'unverifiable' + }) + ).toEqual({ + code: 'agent_session_conflict', + message: 'm', + details: { ownerVerdict: 'unverifiable' }, + ownerVerdict: 'unverifiable' + }) + }) + + // Each expected value is the literal the host built for the same situation before details. + it.each([ + [ + 'a moved prompt', + refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: 2, resolution: RESOLUTION }, + 'Item i has moved on.' + ), + { + code: 'agent_session_item_revision_stale', + message: 'Item i has moved on.', + currentRevision: 2, + resolution: RESOLUTION + } + ], + [ + 'a resolved prompt', + refuse( + 'agent_session_already_resolved', + { reason: 'promptAlreadyResolved', currentRevision: 2, resolution: RESOLUTION }, + 'Item i was already resolved.' + ), + { + code: 'agent_session_already_resolved', + message: 'Item i was already resolved.', + currentRevision: 2, + resolution: RESOLUTION + } + ], + [ + 'a stale fence', + refuse('agent_session_checkpoint_stale', { reason: 'fenceStale', currentFence: 4 }, 'm'), + { code: 'agent_session_checkpoint_stale', message: 'm', currentFence: 4 } + ], + [ + 'a refused rewind', + refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason: 'busy' }, + 'agent_session_rewind:busy' + ), + { + code: 'agent_session_operation_invalid', + message: 'agent_session_rewind:busy', + rewindReason: 'busy' + } + ], + [ + 'a failed create', + withAgentSessionRefusalFacts( + refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'exited 1'), + { ownerVerdict: 'exited' } + ), + { code: 'agent_session_operation_invalid', message: 'exited 1', ownerVerdict: 'exited' } + ], + [ + 'a refusal with no facts', + refuse('agent_session_conflict', { reason: 'claimConflicted' }, 'm'), + { code: 'agent_session_conflict', message: 'm' } + ] + ] as const)('read by a released client exactly as before for %s', (_label, refusal, before) => { + expect(asReleasedClientSeesIt(refusal)).toEqual(before) + }) +}) + +describe('a stored refusal read back', () => { + it('keeps what the code lists and drops the rest', () => { + expect( + readAgentSessionRefusalDetails('agent_session_checkpoint_stale', { + reason: 'fenceStale', + currentFence: 3, + currentRevision: 8, + cause: 'fenceStale' + }) + ).toEqual({ reason: 'fenceStale', currentFence: 3 }) + // A reason a newer host added reads as none, but the facts beside it still count. + expect( + readAgentSessionRefusalDetails('agent_session_checkpoint_stale', { + reason: 'futureReason', + currentFence: 3 + }) + ).toEqual({ currentFence: 3 }) + expect(readAgentSessionRefusalDetails('agent_session_conflict', { reason: 'x' })).toBe( + undefined + ) + }) + + it('round-trips through its reference, and reads an older shape as naming nothing', () => { + const refusal = refuse( + 'agent_session_ownership_unknown', + { reason: 'noLiveOwner', ownerVerdict: 'live' }, + 'm' + ) + const reference = agentSessionRefusalReference(refusal) + expect(reference).toEqual({ + code: 'agent_session_ownership_unknown', + details: { reason: 'noLiveOwner', ownerVerdict: 'live' } + }) + expect(readAgentSessionRefusalReference(JSON.parse(JSON.stringify(reference)))).toEqual( + reference + ) + expect( + readAgentSessionRefusalReference({ code: 'agent_session_conflict', cause: 'claimConflicted' }) + ).toEqual({ code: 'agent_session_conflict' }) + expect(readAgentSessionRefusalReference({ code: 'agent_session_future' })).toBeUndefined() + }) +}) diff --git a/src/shared/agent-session-wire-refusals.ts b/src/shared/agent-session-wire-refusals.ts index 3e686a027ee..8fd1602377b 100644 --- a/src/shared/agent-session-wire-refusals.ts +++ b/src/shared/agent-session-wire-refusals.ts @@ -1,8 +1,24 @@ // How the host declines an `agentSession.*` mutation: the closed code list, its // narrowing guard, and the refusal body a client reads. -import type { AgentJournalResolution } from './agent-session-journal-types' -import type { AgentSessionRewindReason } from './agent-session-rewind' +import { + agentSessionLegacyRefusalFields, + readAgentSessionRefusalDetails, + type AgentSessionAnyRefusalDetails, + type AgentSessionLegacyRefusalFields, + type AgentSessionRefusalDetails, + type AgentSessionRefusalDetailsByCode, + type AgentSessionRefusalFacts +} from './agent-session-refusal-details' + +export type { + AgentSessionAnyRefusalDetails, + AgentSessionAnyRefusalReason, + AgentSessionLegacyRefusalFields, + AgentSessionRefusalDetails, + AgentSessionRefusalDetailsByCode, + AgentSessionRefusalReason +} from './agent-session-refusal-details' export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ 'structured_agent_session_unsupported', @@ -38,132 +54,118 @@ export function isAgentSessionWireRefusalCode( /** What the host last proved about a session's provider process; see the SSH execution boundary. */ export type AgentSessionOwnerVerdict = 'live' | 'unverifiable' | 'exited' -/** - * Why the host refused, one per situation with its own honest next step. A code alone cannot say: - * `agent_session_operation_invalid` covers a cleared conversation (go to the current one) and a - * busy command (wait), and `agent_session_ownership_unknown` covers an unattached session (wait) - * and a replay the lease moved past (start again). Set at every emitter a chat write can reach; - * terminal-agent refusals carry none. Absent from older hosts, so a reader falls back to the code. - */ -export const AGENT_SESSION_REFUSAL_CAUSES = [ - // The request itself - 'operationIdInvalid', - 'operationIdReused', - 'operationExpired', - 'operationCapacity', - 'operationRefusedEarlier', - 'outcomeUnknown', - 'resultLost', - 'fingerprintMismatch', - 'requestMalformed', - 'messageIdReused', - 'clientCapabilityMissing', - // The host - 'hostDisabled', - 'hostUnsupported', - 'hostReconciling', - 'hostFault', - 'recordUnreadable', - 'recordMissing', - 'journalUnreadable', - 'journalWriteFailed', - // The session's owner and lease - 'sessionNotAttached', - 'noLiveOwner', - 'noProviderChild', - 'chatStarting', - 'ownerUnproven', - 'ownerAlive', - 'exitUnproven', - 'claimConflicted', - 'settlementPending', - 'notResumable', - 'leaseMoved', - 'replaySuperseded', - 'spawnIdentityMismatch', - 'identityMismatch', - 'fenceStale', - 'handoffInFlight', - // Creating or adopting a chat - 'sessionExists', - 'tabIdTaken', - 'tabUnconfirmed', - 'conversationHeldElsewhere', - 'conversationUnavailable', - 'transcriptNotFound', - 'transcriptUnreadable', - // The provider - 'providerStarting', - 'providerStartFailed', - 'providerRejected', - 'notSignedIn', - 'historyTooLarge', - 'optionRejected', - 'goalsUnsupported', - // The conversation's state - 'conversationCleared', - 'conversationCommandInFlight', - 'conversationCommandUnconfirmed', - 'rewindRefused', - 'rewindUnconfirmed', - 'turnActive', - 'promptPending', - 'backgroundTasksRunning', - 'messagesUnsettled', - // A prompt card - 'promptGone', - 'promptMoved', - 'promptAlreadyResolved' -] as const -export type AgentSessionRefusalCause = (typeof AGENT_SESSION_REFUSAL_CAUSES)[number] +/** One variant per code, so a refusal's details can only be that code's. */ +export type AgentSessionRefusalOf = { + [K in C]: { + code: K + /** For logs and released clients, which print it; newer clients choose words from details. */ + message: string + /** Absent from older hosts. */ + details?: AgentSessionRefusalDetails + } & AgentSessionLegacyRefusalFields +}[C] -export function isAgentSessionRefusalCause(value: unknown): value is AgentSessionRefusalCause { - return typeof value === 'string' && AGENT_SESSION_REFUSAL_CAUSES.some((cause) => cause === value) -} +export type AgentSessionWireRefusal = AgentSessionRefusalOf /** A refusal named without its prose: what a durable record or an error's data keeps. */ export type AgentSessionRefusalReference = { - code: AgentSessionWireRefusalCode - cause?: AgentSessionRefusalCause + [K in AgentSessionWireRefusalCode]: { code: K; details?: AgentSessionRefusalDetails } +}[AgentSessionWireRefusalCode] + +function buildRefusal( + code: AgentSessionWireRefusalCode, + details: AgentSessionAnyRefusalDetails | undefined, + message: string +): AgentSessionWireRefusal { + const kept = details && Object.keys(details).length > 0 ? details : undefined + const refusal = { + code, + message, + ...(kept ? { details: kept } : {}), + ...agentSessionLegacyRefusalFields(kept) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every caller passes details typed for `code` (refuse and refuseUnclassified by their signatures, the reference by its own union). + return refusal as AgentSessionWireRefusal } -export type AgentSessionWireRefusal = { - rewindReason?: AgentSessionRewindReason - code: AgentSessionWireRefusalCode - /** Absent from older hosts and from refusals no chat write reaches. */ - cause?: AgentSessionRefusalCause +/** A refusal built at its emitter. The reason must be one `code` lists; the legacy top-level fields + * released clients read are copied from `details` here and nowhere else. */ +export function refuse( + code: C, + details: NoInfer, message: string - /** On a stale fence, so the client can retry without another round trip. */ - currentFence?: number - /** On a lost compare-and-set: the winning answer and who gave it. */ - resolution?: AgentJournalResolution - /** On a lost compare-and-set: the revision the host actually holds. */ - currentRevision?: number - /** On a durably failed create: `exited` proves nothing runs for the session, so a new - * operation cannot collide with this one. Absent (older hosts) reads as unverifiable. */ - ownerVerdict?: AgentSessionOwnerVerdict +): AgentSessionWireRefusal { + return buildRefusal(code, details, message) } -/** A refusal built at its emitter, with the situation it stands for. */ -export function refuse( - code: AgentSessionWireRefusalCode, - cause: AgentSessionRefusalCause, +/** + * A refusal whose situation this site cannot name: the code, and whatever facts it does know, with + * no reason, exactly as an older host sends it. The explicit path, so no emitter reaches for a + * catch-all reason instead. + */ +export function refuseUnclassified( + code: C, + message: string, + facts?: NoInfer> +): AgentSessionWireRefusal { + return buildRefusal(code, facts, message) +} + +/** A refusal rebuilt from what a record or a decision kept of it. */ +export function agentSessionRefusalFromReference( + reference: AgentSessionRefusalReference, message: string ): AgentSessionWireRefusal { - return { code, cause, message } + return buildRefusal(reference.code, reference.details, message) +} + +/** The same refusal with more facts: a failed create's verdict, learned after the refusal was. */ +export function withAgentSessionRefusalFacts( + refusal: AgentSessionWireRefusal, + facts: { ownerVerdict: AgentSessionOwnerVerdict } +): AgentSessionWireRefusal { + return buildRefusal(refusal.code, { ...refusal.details, ...facts }, refusal.message) } /** The refusal reduced to what may be stored or put in an error's data. */ export function agentSessionRefusalReference( - refusal: Pick + refusal: AgentSessionWireRefusal ): AgentSessionRefusalReference { - return { code: refusal.code, ...(refusal.cause ? { cause: refusal.cause } : {}) } + return buildReference(refusal.code, refusal.details) +} + +/** A reference read back from a record another build may have written; details are checked + * against the code, and anything else they held (an older build's `cause`) is dropped. */ +export function readAgentSessionRefusalReference( + value: unknown +): AgentSessionRefusalReference | undefined { + if ( + typeof value !== 'object' || + value === null || + !('code' in value) || + !isAgentSessionWireRefusalCode(value.code) + ) { + return undefined + } + return buildReference( + value.code, + 'details' in value ? readAgentSessionRefusalDetails(value.code, value.details) : undefined + ) +} + +function buildReference( + code: AgentSessionWireRefusalCode, + details: AgentSessionAnyRefusalDetails | undefined +): AgentSessionRefusalReference { + const reference = { code, ...(details ? { details } : {}) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `details` is the refusal's own, or was read against `code`. + return reference as AgentSessionRefusalReference } /** * A refusal raised rather than returned. Its message is the bare code, exactly as the * `Error(code)` it replaces: the RPC passthrough, the restart-resume ledger and released clients - * all read a thrown refusal's message as its code. The cause rides on `refusal` and reaches the + * all read a thrown refusal's message as its code. The details ride on `refusal` and reach the * client only in the RPC error's data. Deliberately no `code` property, so no `'code' in error` * passthrough meant for another subsystem can claim it. */ @@ -177,13 +179,13 @@ export class AgentSessionRefusalError extends Error { } } -/** A store or host refusal thrown under its situation; the message stays the code. */ -export function agentSessionRefusalError( - code: AgentSessionWireRefusalCode, - cause: AgentSessionRefusalCause, +/** A store or host refusal thrown under its situation; the error's message stays the code. */ +export function agentSessionRefusalError( + code: C, + details: NoInfer, message: string = code ): AgentSessionRefusalError { - return new AgentSessionRefusalError(refuse(code, cause, message)) + return new AgentSessionRefusalError(refuse(code, details, message)) } export function isAgentSessionRefusalError(error: unknown): error is AgentSessionRefusalError { 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 081/122] 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 6b50dfa797e..7fedea12477 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 73bc984c7658753d73f435a7480b6819f6c6eb7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:02:47 -0700 Subject: [PATCH 082/122] test(native-chat): a restart-resume failure keeps its refusal details The recovery capsule reads a failure's details back against the refusal code in its reason: facts the code does not list and a reason another code owns are dropped, and a record an unreleased build wrote with a cause still parses, naming nothing. --- .../agent-session-recovery-capsule.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 2150e5c7d51..d70fde57af3 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -342,6 +342,41 @@ describe('durable restart offers', () => { expect(JSON.parse(await readFile(filePath, 'utf8')).failed).toEqual([readable]) }) + it('keeps refusal details beside the code, read back against that code', async () => { + const readable = { + marker: marker(), + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_conflict', + latestPrompt: '', + latestUserItemId: null + } + await writeFile( + filePath, + JSON.stringify({ + version: 2, + entries: [], + failed: [ + { ...readable, details: { reason: 'claimConflicted', note: 'dropped' } }, + // An unreleased build wrote a cause here; it still parses, naming nothing. + { ...readable, marker: marker({ sessionId: 'older' }), cause: 'claimConflicted' }, + // A reason the code does not list is not this code's. + { + ...readable, + marker: marker({ sessionId: 'foreign' }), + details: { reason: 'promptGone' } + } + ] + }) + ) + + expect(await capsule.listFailed(NOW)).toEqual([ + { ...readable, details: { reason: 'claimConflicted' } }, + { ...readable, marker: marker({ sessionId: 'older' }) }, + { ...readable, marker: marker({ sessionId: 'foreign' }) } + ]) + }) + it('reads a malformed failure list as no failures', async () => { await writeFile( filePath, From e7cf9625077171a70a83e1a4d3c970f41d729f01 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:19:08 -0700 Subject: [PATCH 083/122] test(native-chat): import the failure words once in the provider child test The merge left two imports of the same modules. --- ...tured-agent-session-provider-child-record.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 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 c217f5b1441..75219c8f152 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 @@ -13,8 +13,14 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureSentence } from '../../../shared/agent-session-failure-words' +import { + agentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureSentence, + agentSessionFailureWords +} from '../../../shared/agent-session-failure-words' 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' @@ -32,8 +38,6 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } 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 084/122] 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 6941d5f7261..d6e5e9097a2 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 97f55563b4f..591c09d8e32 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 4b8037077aa..1e2322cbc6d 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 19d04903987..07d1fb6b5ff 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 41b83e77eae..dab9ea639c6 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 ee232450754..ffd06ae4dac 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 085/122] 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 7fedea12477..b8e2d73c27f 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 4634735d4c5b5b34552bb2bcb3995e433ab03acb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:54:11 -0700 Subject: [PATCH 086/122] fix(native-chat): a start the provider refused or Orca broke no longer says the provider stopped A failed start whose cleanup proved the child gone was typed as `providerStartFailed` whatever failed it: Codex refusing to resume a thread, a timeout, or Orca's own store fault. The chat then read "The provider stopped before it finished starting.", which was untrue, and the provider's own words were dropped. A refused restart also inferred the same from an `exited` verdict, which only says nothing runs now. Only an exit the adapter observed names that situation now; anything else is refused with no reason, keeping its verdict, and the chat reads " couldn't restart.". The provider's words travel host-side from where the acquisition failed into the start-failure fact's detail, never onto the refusal, and the sentence does not quote them. What failed is logged once where the start failed. --- ...-agent-session-accept-then-deliver.test.ts | 28 +++-- ...-agent-session-acquisition-options.test.ts | 5 +- .../structured-agent-session-attach-flow.ts | 11 +- ...ured-agent-session-attach-orchestration.ts | 4 +- .../structured-agent-session-delivery-loop.ts | 6 +- ...ession-failed-create-owner-verdict.test.ts | 105 +++++++++++------- ...red-agent-session-failed-create-refusal.ts | 37 +++--- ...uctured-agent-session-failure-text.test.ts | 44 ++++++-- .../structured-agent-session-failure-text.ts | 45 +++++--- .../structured-agent-session-hold-resume.ts | 39 +++++-- .../structured-agent-session-host.test.ts | 3 +- ...ured-agent-session-refusal-details.test.ts | 21 +++- ...red-agent-session-send-preparation.test.ts | 40 ++++++- 13 files changed, 269 insertions(+), 119 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 02d698163cf..c248468d779 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 @@ -316,14 +316,19 @@ describe('a start the chat needed and did not get', () => { await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) const rows = errorRows() expect(rows).toHaveLength(1) - // Orca's spawn error goes to the log; the row and every message say what failed, typed. - expect(rows[0]).toBe('The provider stopped before it finished starting.') - expect(errorFailures()).toEqual([{ kind: 'providerStartFailed' }]) + // Orca's spawn error goes to the log; the row and every message say what failed, typed. The + // child is gone, but no exit was observed, so nothing blames the provider. + expect(rows[0]).toBe("Codex couldn't restart.") + const failure = { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(errorFailures()).toEqual([failure]) for (const id of [first, second]) { expect(submission(id)).toMatchObject({ dispatchState: 'rejected', reason: rows[0], - rejection: { kind: 'providerStartFailed' } + rejection: failure }) } @@ -353,8 +358,11 @@ describe('a start the chat needed and did not get', () => { 'spawn', () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), { - text: 'The provider stopped before it finished starting.', - failure: { kind: 'providerStartFailed' } + text: "Codex couldn't restart.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } } ], [ @@ -364,8 +372,12 @@ describe('a start the chat needed and did not get', () => { new AgentSessionPreSpawnError(new Error('Not logged in. Please run /login.')) ), { - text: 'The provider stopped before it finished starting.', - failure: { kind: 'providerStartFailed' } + // No process ever started, so nothing says the provider stopped. + text: "Codex couldn't restart.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } } ] ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { 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 a2b627cae73..0fa862b6be3 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 @@ -432,7 +432,6 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - details: { reason: 'providerStartFailed' }, message: 'model list unavailable' } }) @@ -525,14 +524,14 @@ describe('structured session acquisition options', () => { onAttached: () => {} }) - // A proven exit before the journal opens is answered once, as the refusal its replay gives. + // A proven exit before the journal opens is answered once, as the refusal its replay gives; + // no exit was observed, so it names no situation. const failed = perform(store, CREATE_OPERATION, null) await (exitProven && failurePoint !== 'journal' ? expect(failed).resolves.toEqual({ ok: false, refusal: { code: 'agent_session_operation_invalid', - details: { reason: 'providerStartFailed' }, message: injected.message } }) 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 bcc8f53b9d8..7a590d9d213 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 @@ -73,6 +73,9 @@ export type AttachFlowInput = { /** 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 + /** The error an acquisition failed with, for a host-side reader of the provider's words; the + * refusal never carries them. */ + onAcquisitionFailed?: (error: unknown) => void } export async function performAttach( @@ -194,8 +197,14 @@ export async function performAttach( ) } } + input.onAcquisitionFailed?.(error) + const failed = failedAcquisitionRefusal(error) + if (failed && !failed.refusal.details?.reason) { + // The refusal names no situation, so what failed is kept here. + console.warn('[agent-session] provider start failed:', error) + } return ( - failedAcquisitionRefusal(error) ?? { + failed ?? { ok: false, refusal: classifyStoreFailure( error, 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 4987ec5df8b..e2a5346ad1e 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 @@ -15,7 +15,7 @@ import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' +import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { pinnedAgentSessionLaunchArgs, @@ -46,6 +46,7 @@ export type StructuredAgentSessionAttachOptions = { /** Provider-exit recovery: refuses once the ticket the restart was issued for is stale. */ admitRecoveryTicket?: () => boolean recordPhase?: AgentSessionCreatePhaseRecorder + onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] } /** @@ -160,6 +161,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, + ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId) if (!conversation) { 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 58bf2dda7ba..89a52007f4d 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 @@ -13,7 +13,6 @@ import { agentSessionFailureFact, type AgentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionFailureWords, type AgentSessionFailureWordsContext @@ -55,7 +54,7 @@ type Step = 'continue' | 'stop' type Prepared = | 'stop' - | { ok: false; refusal: AgentSessionWireRefusal } + | Extract | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } /** A failed start before it is worded; `fail` words it once, through the one wording point. */ @@ -96,7 +95,8 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - const cause = { refusal: prepared.refusal } + const { refusal, diagnostic } = prepared + const cause = { refusal, ...(diagnostic ? { diagnostic } : {}) } await this.deps.serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause }) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 37838faa546..f178ef6c95a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -13,6 +13,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withObservedProviderExit } from './structured-agent-session-failure-text' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -66,53 +67,73 @@ afterEach(async () => { }) describe('failed create owner verdict', () => { - it('answers an exit-proven failure as exited on the first call and its replay, and a new operation starts fresh', async () => { - // The cleanup's release proves the whole tree gone: the common failed start. - acquire.mockRejectedValueOnce(new Error(EXIT_REASON)) - const first = hostTestAttachParams(null) - // The replay names the same details as the first answer: the ledger kept them beside the code, - // and the verdict reaches released clients at the top level exactly as before. - const refusal = { - code: 'agent_session_operation_invalid', - details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, - message: EXIT_REASON, - ownerVerdict: 'exited' - } - - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + it.each([ + // The cleanup's release proves the whole tree gone, which says nothing about why it failed. + ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}], + // Only an exit the adapter saw says the provider stopped. + [ + 'an exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' } + ] + ])( + 'answers %s as exited on the first call and its replay, and a new operation starts fresh', + async (_case, failure, situation) => { + acquire.mockRejectedValueOnce(failure()) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message: EXIT_REASON, + ownerVerdict: 'exited' + } - const retry = hostTestAttachParams(null) - expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() - it('answers a first-hand root exit as exited on the first call, in the shape its replay takes', async () => { - acquire.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error(EXIT_REASON)) - ) - const first = hostTestAttachParams(null) - // The replay names the same details as the first answer: the ledger kept them beside the code, - // and the verdict reaches released clients at the top level exactly as before. - const refusal = { - code: 'agent_session_operation_invalid', - details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, - message: EXIT_REASON, - ownerVerdict: 'exited' + const retry = hostTestAttachParams(null) + expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } + ) + + it.each([ + // A cleanup that saw the root go may have stopped it itself. + ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}], + [ + 'a root exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' } + ] + ])( + 'answers %s as exited on the first call, in the shape its replay takes', + async (_case, cause, situation) => { + acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message: EXIT_REASON, + ownerVerdict: 'exited' + } - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(2) - }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + expect(acquire).toHaveBeenCalledTimes(2) + } + ) it('answers an acquisition refusal with its verdict directly', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index 38f05a42a38..b35047e0e4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -1,5 +1,6 @@ import { refuse, + refuseUnclassified, withAgentSessionRefusalFacts, type AgentSessionAttachResult, type AgentSessionMutationResult, @@ -20,6 +21,7 @@ import { AgentSessionAcquisitionRootExitObservedError, isAgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { providerExitObserved } from './structured-agent-session-failure-text' /** What a failed acquisition proved about its process, and the outcome its operation settles to. */ export function failedAcquisitionSettlement(error: unknown): { @@ -52,19 +54,24 @@ export function failedAcquisitionSettlement(error: unknown): { } } -/** The situation a failed acquisition stands for: what the adapter typed, or a provider that died - * starting. A store refusal names its situation under its own code, which this row does not - * carry, and a failure before the spawn or Orca's own fault names none. */ +/** Cleanup proved the child gone after the start failed, whatever failed it. */ +function isExitProvenAcquisitionFailure(error: unknown): error is Error { + return ( + error instanceof AgentSessionAcquisitionRootExitObservedError || + error instanceof AgentSessionAcquisitionExitProvenError + ) +} + +/** The situation a failed acquisition stands for: what the adapter typed, or a provider the + * adapter saw exit while starting. A provider refusing a request, a timeout, a store refusal or + * Orca's own fault names none: the child being gone now says nothing about why. */ function failedAcquisitionDetails( error: unknown ): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { if (error instanceof AgentSessionAcquisitionRefusal) { return { reason: error.reason } } - if ( - error instanceof AgentSessionAcquisitionRootExitObservedError || - error instanceof AgentSessionAcquisitionExitProvenError - ) { + if (isExitProvenAcquisitionFailure(error) && providerExitObserved(error)) { return { reason: 'providerStartFailed' } } return undefined @@ -78,18 +85,14 @@ export function failedAcquisitionRefusal( if (error instanceof AgentSessionAcquisitionRefusal) { return { ok: false, refusal: refuse(error.code, { reason: error.reason }, error.message) } } - // A proven exit is a settled fact; its message is the provider's own diagnostic. - if ( - error instanceof AgentSessionAcquisitionRootExitObservedError || - error instanceof AgentSessionAcquisitionExitProvenError - ) { + // A proven exit is a settled failure, answered in the shape its ledger row replays. + if (isExitProvenAcquisitionFailure(error)) { + const details = failedAcquisitionDetails(error) return { ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: 'providerStartFailed' }, - error.message - ) + refusal: details + ? refuse('agent_session_operation_invalid', details, error.message) + : refuseUnclassified('agent_session_operation_invalid', error.message) } } return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index aeb26c6265f..c33e8f9af43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -4,7 +4,11 @@ import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' -import { AgentSessionRefusalError, refuse } from '../../../shared/agent-session-wire-refusals' +import { + AgentSessionRefusalError, + refuse, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' import { @@ -103,16 +107,40 @@ describe('structuredAgentSessionStartFailure', () => { }) it('says the provider stopped only when an exit was observed', () => { - const exited = structuredAgentSessionStartFailure({ - refusal: refuse( - 'agent_session_ownership_unknown', - { reason: 'ownerUnproven', ownerVerdict: 'exited' }, - 'probe saw an exit' + // An `exited` verdict says only that nothing runs now, not that the provider stopped. + const gone = refuseUnclassified('agent_session_operation_invalid', 'thread gone', { + ownerVerdict: 'exited' + }) + const person = { text: 'no rollout found for thread id T', audience: 'person' } as const + expect( + structuredAgentSessionStartFailure( + { refusal: gone, diagnostic: person }, + { agentName: 'Codex' } ) + ).toEqual({ + reason: "Codex couldn't restart.", + rejection: { + kind: 'restartFailed', + detail: person, + refusal: { + code: 'agent_session_operation_invalid', + details: { ownerVerdict: 'exited' } + } + } }) - expect(exited).toEqual({ + const observed = refuse( + 'agent_session_operation_invalid', + { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + 'exited (code 1)' + ) + expect( + structuredAgentSessionStartFailure({ + refusal: observed, + diagnostic: { text: 'code 1', audience: 'log' } + }) + ).toEqual({ reason: 'The provider stopped before it finished starting.', - rejection: { kind: 'providerStartFailed' } + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } }) // A start that threw proves nothing about the provider: it may be Orca's, or a failed spawn. expect( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 38b83a54a01..480fc604dca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -23,15 +23,22 @@ export function withObservedProviderExit(error: TError): T return Object.assign(error, { providerExitObserved: true }) } -function providerExitObserved(error: unknown): boolean { - let current: unknown = error - for (let depth = 0; depth < 6 && current instanceof Error; depth += 1) { - if ('providerExitObserved' in current && current.providerExitObserved === true) { - return true - } - current = current.cause +/** Whether the adapter saw the child exit on its own. Follows `cause` and the errors a cleanup + * aggregated, since the acquisition errors wrap what the adapter threw. */ +export function providerExitObserved(error: unknown, depth = 0): boolean { + if (depth >= 6 || !(error instanceof Error)) { + return false } - return false + if ('providerExitObserved' in error && error.providerExitObserved === true) { + return true + } + if ( + error instanceof AggregateError && + error.errors.some((inner) => providerExitObserved(inner, depth + 1)) + ) { + return true + } + return providerExitObserved(error.cause, depth + 1) } /** A start that did not land. A refusal the adapter typed keeps its situation, and an exit the @@ -61,25 +68,31 @@ function startupFailureFromExit( return failure } -/** What the chat records when the delivery loop could not make the session ready. */ -function restartFailureFact(refusal: AgentSessionWireRefusal): AgentSessionFailureFact { +/** What the chat records when a session could not be made ready. Only a refusal the host typed as + * an observed exit says the provider stopped; a verdict of `exited` means only that nothing runs + * now. */ +function refusedStartFailureFact( + cause: Extract +): AgentSessionFailureFact { + const { refusal, diagnostic } = cause const reason = refusal.details?.reason if (reason === 'notSignedIn' || reason === 'historyTooLarge') { return agentSessionFailureFact(reason) } - // A child that died starting reads as any start that died does. - if (refusal.details?.ownerVerdict === 'exited' || reason === 'providerStartFailed') { - return agentSessionFailureFact('providerStartFailed') + if (reason === 'providerStartFailed') { + return agentSessionFailureFact('providerStartFailed', { detail: diagnostic }) } return agentSessionFailureFact('restartFailed', { + detail: diagnostic, refusal: agentSessionRefusalReference(refusal) }) } /** Why a start the chat needed did not land, as the place that saw it knows it. */ export type StructuredAgentSessionStartFailureCause = - /** The session could not be made ready. */ - | { refusal: AgentSessionWireRefusal } + /** The session could not be made ready; the provider's words, if any, are kept host-side, off + * the refusal. */ + | { refusal: AgentSessionWireRefusal; diagnostic?: ProviderDiagnostic } /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ | { error: unknown } /** The child ended before it proved its start, as its ended event told it. */ @@ -98,7 +111,7 @@ export type StructuredAgentSessionStartFailureWords = AgentJournalDispatchReject function startFailureFact(cause: StructuredAgentSessionStartFailureCause): AgentSessionFailureFact { if ('refusal' in cause) { - return restartFailureFact(cause.refusal) + return refusedStartFailureFact(cause) } if ('error' in cause) { return providerStartupFailureFact(cause.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts index 1db5c569e65..9e0fa82e170 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts @@ -7,6 +7,10 @@ // on. A write-capable hold must fail when acquisition is refused so the surface never mistakes a // readable journal for a live provider child. +import { + providerDiagnosticOf, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' import { agentSessionRefusalFromReference, readAgentSessionRefusalReference, @@ -36,7 +40,13 @@ import { * someone else is settling from an owner that will not come back. */ export type StructuredAgentSessionResumeOutcome = | { ok: true } - | { ok: false; refusal: AgentSessionWireRefusal } + | { + ok: false + refusal: AgentSessionWireRefusal + /** What the provider said about the failed start, for the chat's own record; host-side + * only, never on the refusal. */ + diagnostic?: ProviderDiagnostic + } export async function resumeHeldStructuredAgentSession(input: { sessionId: string @@ -93,13 +103,14 @@ export async function resumeHeldStructuredAgentSession(input: { ) } let attached: AgentSessionMutationResult + let acquisitionError: unknown try { - attached = await attachStructuredAgentSessionUnderSerialize( - context, - callerKey, - params, - input.attachOptions - ) + attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { + ...input.attachOptions, + onAcquisitionFailed: (error) => { + acquisitionError = error + } + }) } catch (error) { // The attach settles an acquisition that failed — the ledger row, the released lease — before // it rethrows the cause. That row is the answer: a failure it recorded is this resume's @@ -112,11 +123,19 @@ export async function resumeHeldStructuredAgentSession(input: { error ) if (settled) { - return settled + return withDiagnostic(settled.refusal, error) } throw error } - return attached.ok ? { ok: true } : { ok: false, refusal: attached.refusal } + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) +} + +function withDiagnostic( + refusal: AgentSessionWireRefusal, + error: unknown +): StructuredAgentSessionResumeOutcome { + const diagnostic = providerDiagnosticOf(error) + return { ok: false, refusal, ...(diagnostic ? { diagnostic } : {}) } } function settledResumeRefusal( @@ -125,7 +144,7 @@ function settledResumeRefusal( operationId: string, sessionId: string, error: unknown -): StructuredAgentSessionResumeOutcome | null { +): { ok: false; refusal: AgentSessionWireRefusal } | null { const outcome = context.deps.store.getOperationRow(callerKey, operationId)?.outcome const reference = outcome?.status === 'failed' 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 e23d1e5cfc6..ad547b4ac7a 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 @@ -139,11 +139,12 @@ describe('attach', () => { }) const params = attachParams() + // Orca's own store fault: the child is gone, but nothing blames the provider. const refused = { ok: false, refusal: { code: 'agent_session_operation_invalid', - details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + details: { ownerVerdict: 'exited' }, message: 'agent_session_provider_handle_stale_fence', ownerVerdict: 'exited' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts index 9c8a4b98955..f42a17c310b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -17,6 +17,7 @@ import { failedAcquisitionRefusal, failedAcquisitionSettlement } from './structured-agent-session-failed-create-refusal' +import { withObservedProviderExit } from './structured-agent-session-failure-text' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' function replay(outcome: Parameters[0]['outcome']) { @@ -29,16 +30,26 @@ function replay(outcome: Parameters[0][ describe('a ledger replay names the details its first answer did', () => { it.each([ - new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn'), - new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')) - ])('for a failed create: %s', (error) => { + [new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn'), 'notSignedIn'], + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit(new Error('exited (code 1)')) + ), + 'providerStartFailed' + ], + // Gone now, with no exit observed: no situation, on the first answer or the replay. + [new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')), undefined] + ])('for a failed create: %s', (error, reason) => { const first = failedAcquisitionRefusal(error) const replayed = replay(failedAcquisitionSettlement(error).outcome) - expect(first?.refusal.details?.reason).toBeDefined() + expect(first?.refusal.details?.reason).toBe(reason) expect(replayed).toMatchObject({ decision: 'refuse', - refusal: { code: first?.refusal.code, details: first?.refusal.details } + refusal: { code: first?.refusal.code, ...(reason ? { details: first?.refusal.details } : {}) } }) + if (!reason) { + expect(replayed).not.toHaveProperty('refusal.details') + } }) it('for a create whose cleanup could not prove the child gone', () => { 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 3482a2a1a22..fa63c855b3f 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 @@ -13,6 +13,7 @@ import type { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -424,15 +425,16 @@ describe('a send with no live owner', () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') - // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. - const cause = 'The provider stopped before it finished starting.' + // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit + // was observed, so the chat does not say the provider stopped. + const cause = "Codex couldn't restart." const id = await accept(params) expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause, - rejection: { kind: 'providerStartFailed' } + rejection: { kind: 'restartFailed' } }) expect(dispatch).not.toHaveBeenCalled() // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. @@ -443,6 +445,36 @@ describe('a send with no live owner', () => { expect(errorStatuses()).toEqual([cause]) }) + it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { + await loseOwner() + const said = `no rollout found for thread id ${THREAD}` + acquire.mockRejectedValue( + new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const id = await accept(sendParams('after the thread went away')) + + // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. + const rejection = { + kind: 'restartFailed', + detail: { text: said, audience: 'person' }, + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart.", + rejection + }) + expect(errorStatuses()).toEqual(["Codex couldn't restart."]) + // Orca's own text is logged once where the start failed. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: `thread/resume failed: ${said}` }) + ) + warn.mockRestore() + }) + 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')) @@ -732,7 +764,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - rejection: { kind: 'providerStartFailed' } + rejection: { kind: 'restartFailed' } }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) const published = frames.slice(subscribed) From b532cd99adc9ce2b00756099cdbad2666e08749e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:56:23 -0700 Subject: [PATCH 087/122] fix(native-chat): a refused /clear start keeps the situation it named The replacement start that /clear makes built its own start-failure fact, so a refusal that named its situation, such as not being signed in or a history too large to restore, was recorded as a bare "couldn't start". It now takes its fact from the same start-failure function as every other start, as a new session that failed to start. --- .../structured-agent-session-failure-text.ts | 16 +++++++++----- .../structured-conversation-command.test.ts | 22 ++++++++++++++++++- .../structured-conversation-command.ts | 7 +++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 480fc604dca..77088ef7e5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -82,7 +82,7 @@ function refusedStartFailureFact( if (reason === 'providerStartFailed') { return agentSessionFailureFact('providerStartFailed', { detail: diagnostic }) } - return agentSessionFailureFact('restartFailed', { + return agentSessionFailureFact(cause.newSession ? 'startFailed' : 'restartFailed', { detail: diagnostic, refusal: agentSessionRefusalReference(refusal) }) @@ -91,8 +91,8 @@ function refusedStartFailureFact( /** Why a start the chat needed did not land, as the place that saw it knows it. */ export type StructuredAgentSessionStartFailureCause = /** The session could not be made ready; the provider's words, if any, are kept host-side, off - * the refusal. */ - | { refusal: AgentSessionWireRefusal; diagnostic?: ProviderDiagnostic } + * the refusal. `newSession`: one that never ran, so it failed to start rather than restart. */ + | { refusal: AgentSessionWireRefusal; diagnostic?: ProviderDiagnostic; newSession?: true } /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ | { error: unknown } /** The child ended before it proved its start, as its ended event told it. */ @@ -109,7 +109,10 @@ export type StructuredAgentSessionStartFailureCause = * messages the start was for. */ export type StructuredAgentSessionStartFailureWords = AgentJournalDispatchRejection -function startFailureFact(cause: StructuredAgentSessionStartFailureCause): AgentSessionFailureFact { +/** The fact a failed start records, for a writer that words it on its own surface. */ +export function structuredAgentSessionStartFailureFact( + cause: StructuredAgentSessionStartFailureCause +): AgentSessionFailureFact { if ('refusal' in cause) { return refusedStartFailureFact(cause) } @@ -134,5 +137,8 @@ export function structuredAgentSessionStartFailure( cause: StructuredAgentSessionStartFailureCause, context: AgentSessionFailureWordsContext = {} ): StructuredAgentSessionStartFailureWords { - return agentSessionFailureWords(startFailureFact(cause), { ...context, surface: 'rejection' }) + return agentSessionFailureWords(structuredAgentSessionStartFailureFact(cause), { + ...context, + surface: 'rejection' + }) } 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 8d8b9b09e51..52c46f4d828 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 @@ -6,7 +6,10 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' import type { StructuredSessionCompactionResult } from './structured-session-compaction' import { HOST_TEST_NOW, @@ -287,6 +290,23 @@ describe('host conversation commands', () => { }) }) + it('keeps the situation a refused replacement start named', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { + state: 'completed', + replacementSessionId: undefined, + error: + 'Codex is not signed in for the selected account. Sign in, then send your message again.', + failure: { kind: 'notSignedIn' } + } + }) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + }) + it('runs a command whose fence the client has not caught up to', async () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ 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 5d7f03e32ad..e49dc40d7a6 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 @@ -27,7 +27,7 @@ import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredSessionCompactionResult } from './structured-session-compaction' @@ -196,8 +196,9 @@ export function runStructuredConversationCommand( phase: 'committed' as const, state: 'completed' as const, ...conversationCommandFailure( - agentSessionFailureFact('startFailed', { - refusal: agentSessionRefusalReference(acquired.refusal) + structuredAgentSessionStartFailureFact({ + refusal: acquired.refusal, + newSession: true }), structuredAgentSessionFailureWordsContext(record) ) From e60047d7d5e41ea6ec1a0a40ed7d0b65617aff8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:59:41 -0700 Subject: [PATCH 088/122] fix(native-chat): the journal schema and comments describe a refusal's details, not its cause The persisted failure fact's schema still described `refusal.cause`, which this branch replaced with `details`. It now describes `details` as an optional open object; a row an earlier build wrote with a `cause` still parses. A comment and three test descriptions that still named the cause now name the details. --- .../structured-agent-session-holds.ts | 2 +- ...d-agent-session-legacy-handoff-record.test.ts | 2 +- ...tured-agent-session-restart-ownership.test.ts | 4 ++-- ...ctured-agent-session-surface-lifetime.test.ts | 2 +- src/shared/agent-session-journal-schemas.test.ts | 16 ++++++++++++++++ src/shared/agent-session-journal-schemas.ts | 6 +++--- 6 files changed, 24 insertions(+), 8 deletions(-) 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 ba7d2965fcb..af53bcb0e0f 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 @@ -91,7 +91,7 @@ export class StructuredAgentSessionHolds { } if (!resumed.ok) { this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - // The RPC surface raises a refusal as its code; the refusal itself rides along for its cause. + // The RPC surface raises a refusal as its code; the refusal itself rides along for its details. throw new AgentSessionRefusalError(resumed.refusal) } } 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 ab5acfa5d34..84a7f0e9108 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 @@ -246,7 +246,7 @@ describe('a record an older build left mid terminal handoff', () => { }) // Sending and opening the chat both say what frees it: quitting that terminal agent. A send is // accepted, then rejected by the start that cannot take the lease, and the chat's row says why, - // worded from the refusal's cause; only the live refusal names the process. + // worded from the refusal's details; only the live refusal names the process. const quitTerminal = 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' expect(await delivered('while the terminal still runs')).toMatchObject({ 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 4128261b028..01840a26d3d 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 @@ -718,8 +718,8 @@ it('logs teardown capsule publication failure and still releases the provider', }) // The resume ledger's reason stays the refusal code, which is what every renderer's guidance keys -// on; the cause is filed beside it, never in its place. -it('files a restart refused by a conflicted claim under its code, with its cause beside it', async () => { +// on; the details are filed beside it, never in its place. +it('files a restart refused by a conflicted claim under its code, with its details beside it', async () => { // The terminal agent that holds the claim is still running, so nothing may take it over. const { host, store } = await interruptedRestart('turn', true, async () => ({ outcome: 'identity-matched', 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 145ed4227f8..d9f299d5f78 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 @@ -717,7 +717,7 @@ describe('an unexpected provider exit', () => { const history = host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — its cause beside it, and never the old bare `Provider exited: ` row. + // written — its failure fact beside it, and never the old bare `Provider exited: ` row. const statuses = history.ok ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) : [] diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 02a156f2b0d..d638f5839d6 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -356,6 +356,22 @@ describe('typed failure facts', () => { expect(isAdmissibleAgentJournalSubmission({ ...submission, rejection: failure })).toBe(true) }) + it("admits a refusal's details, and a row an earlier build wrote with its cause", () => { + const refused = (refusal: object) => + isAdmissibleAgentJournalItemBody({ + kind: 'status', + text: "Codex couldn't restart.", + failure: { kind: 'restartFailed', refusal } + }) + expect( + refused({ + code: 'agent_session_conflict', + details: { reason: 'claimConflicted', futureFact: 1 } + }) + ).toBe(true) + expect(refused({ code: 'agent_session_conflict', cause: 'claimConflicted' })).toBe(true) + }) + it('keeps a kind or audience a newer host writes admissible', () => { expect( isAdmissibleAgentJournalItemBody({ diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 82b316d9306..d13e45f8640 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -200,12 +200,12 @@ const ThreadGoalState = z.union([ z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) ]) -/** Open like `state`: a kind or audience a newer host writes must not turn the row malformed; the - * fact reader is where an unplaceable one is dropped. */ +/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row + * malformed; the fact reader is where an unplaceable one is dropped. */ const FailureFact = z.object({ kind: z.string().min(1), detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), - refusal: z.object({ code: z.string().min(1), cause: z.string().min(1).optional() }).optional() + refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() }) export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ From 2927227ddbe06ef6fbb757889c308375fffda75c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:04:40 -0700 Subject: [PATCH 089/122] fix(native-chat): a Claude child that exits while being acquired still reads as the provider stopping Now that only an exit the adapter observed says the provider stopped, the exit the Claude adapter saw during acquisition has to be marked where it is seen, as the exit after acquisition already is. Without the mark, a Claude CLI that exited at spawn read "Claude couldn't restart." instead of "The provider stopped before it finished starting." --- src/main/claude/claude-structured-session-acquisition.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 6728f960ef0..5e2dff4b0cf 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -30,6 +30,7 @@ import { type ClaudeAcquireCallbacks } from './claude-structured-session-state' import { resolveClaudeAcquisitionError } from './claude-structured-session-close' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' @@ -178,6 +179,8 @@ export async function acquireClaudeSession({ initProof.reject(error) }, onExit: (error) => { + // The child exited on its own; marked in place, as the fault report may hold this error. + withObservedProviderExit(error) childEnded ??= error initProof.reject(error) callbacks.handleExit(sessionId, attempt, error) From 79327d4fde7e881a889fdc50685aefe8fd4d2cfa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:23:07 -0700 Subject: [PATCH 090/122] fix(native-chat): word a failed chat start's refusal as its start failure A chat whose agent failed to start answered the create with the raw error: the launch strip read "Chat could not be started. claude stream-json exited (code 1): claude: not signed in", and the ledger replayed the same text. The first answer and the replay now carry the sentence the chat's start-failure row reads as ("The provider stopped before it finished starting.", "Claude couldn't start.", the not-signed-in and history-too-large sentences), and the raw error goes to the log. A store refusal's code and the unproven-exit marker are unchanged. --- ...-agent-session-acquisition-options.test.ts | 4 +- .../structured-agent-session-attach-flow.ts | 12 ++- ...ession-failed-create-owner-verdict.test.ts | 23 +++--- ...red-agent-session-failed-create-refusal.ts | 54 ++++++++++--- ...session-failed-create-sink-release.test.ts | 5 +- .../structured-agent-session-host.test.ts | 4 +- ...ured-agent-session-refusal-details.test.ts | 77 ++++++++++++++++++- ...agent-session-settled-attach-retry.test.ts | 2 +- 8 files changed, 146 insertions(+), 35 deletions(-) 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 0fa862b6be3..93e1663b76d 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 @@ -432,7 +432,7 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'model list unavailable' + message: "Codex couldn't restart." } }) expect(releaseAcquisition).toHaveBeenCalledOnce() @@ -532,7 +532,7 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: injected.message + message: "Codex couldn't restart." } }) : expect(failed).rejects.toThrow( 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 7a590d9d213..ebe6009320d 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 @@ -177,6 +177,10 @@ export async function performAttach( acquiredOwner = true } } catch (error) { + const wording = { + record: reservedRecord ?? store.getRecord(sessionId), + newSession: !params.providerHandle + } const spawnToken = reservedRecord?.lease.reservedSpawnToken if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // Settle processless proof and failed operation atomically. @@ -187,7 +191,7 @@ export async function performAttach( spawnToken, callerKey: input.callerKey, operationId: params.envelope.clientOperationId, - ...failedAcquisitionSettlement(error), + ...failedAcquisitionSettlement(error, wording), now: input.now() }) } catch (settlementError) { @@ -198,9 +202,9 @@ export async function performAttach( } } input.onAcquisitionFailed?.(error) - const failed = failedAcquisitionRefusal(error) - if (failed && !failed.refusal.details?.reason) { - // The refusal names no situation, so what failed is kept here. + const failed = failedAcquisitionRefusal(error, wording) + if (failed) { + // The refusal carries only its sentence, so what failed is kept here. console.warn('[agent-session] provider start failed:', error) } return ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index f178ef6c95a..0648c610575 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -24,6 +24,9 @@ import { const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' +// The refusal says what the chat's start failure says; the error text stays in the log. +const COULD_NOT_RESTART = "Codex couldn't restart." +const PROVIDER_STOPPED = 'The provider stopped before it finished starting.' let root: string let store: AgentSessionRecordStore @@ -69,16 +72,17 @@ afterEach(async () => { describe('failed create owner verdict', () => { it.each([ // The cleanup's release proves the whole tree gone, which says nothing about why it failed. - ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}], + ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], // Only an exit the adapter saw says the provider stopped. [ 'an exit the adapter observed', () => withObservedProviderExit(new Error(EXIT_REASON)), - { reason: 'providerStartFailed' } + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED ] ])( 'answers %s as exited on the first call and its replay, and a new operation starts fresh', - async (_case, failure, situation) => { + async (_case, failure, situation, message) => { acquire.mockRejectedValueOnce(failure()) const first = hostTestAttachParams(null) // The replay names the same details as the first answer: the ledger kept them beside the code, @@ -86,7 +90,7 @@ describe('failed create owner verdict', () => { const refusal = { code: 'agent_session_operation_invalid', details: { ...situation, ownerVerdict: 'exited' }, - message: EXIT_REASON, + message, ownerVerdict: 'exited' } @@ -104,15 +108,16 @@ describe('failed create owner verdict', () => { it.each([ // A cleanup that saw the root go may have stopped it itself. - ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}], + ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], [ 'a root exit the adapter observed', () => withObservedProviderExit(new Error(EXIT_REASON)), - { reason: 'providerStartFailed' } + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED ] ])( 'answers %s as exited on the first call, in the shape its replay takes', - async (_case, cause, situation) => { + async (_case, cause, situation, message) => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) const first = hostTestAttachParams(null) // The replay names the same details as the first answer: the ledger kept them beside the code, @@ -120,7 +125,7 @@ describe('failed create owner verdict', () => { const refusal = { code: 'agent_session_operation_invalid', details: { ...situation, ownerVerdict: 'exited' }, - message: EXIT_REASON, + message, ownerVerdict: 'exited' } @@ -143,7 +148,7 @@ describe('failed create owner verdict', () => { refusal: { code: 'agent_session_operation_invalid', details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, - message: 'not signed in', + message: PROVIDER_STOPPED, ownerVerdict: 'exited' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index b35047e0e4b..db3520a6323 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -1,4 +1,5 @@ import { + isAgentSessionWireRefusalCode, refuse, refuseUnclassified, withAgentSessionRefusalFacts, @@ -21,10 +22,38 @@ import { AgentSessionAcquisitionRootExitObservedError, isAgentSessionPreSpawnError } from './structured-agent-session-adapter' -import { providerExitObserved } from './structured-agent-session-failure-text' +import { + providerExitObserved, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' + +/** Who a failed acquisition's sentence names, and whether it was the session's first start. */ +export type FailedAcquisitionWording = { + record: AgentSessionRecord | null + newSession: boolean +} + +/** The refusal a failed acquisition answers with, worded as the start failure it records: its + * error text is Orca's or the provider's log wording, so it goes to the log, never the wire. */ +function failedAcquisitionWireRefusal( + details: AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined, + wording: FailedAcquisitionWording +): AgentSessionWireRefusal { + const code = 'agent_session_operation_invalid' + const refusal = details ? refuse(code, details, code) : refuseUnclassified(code, code) + const { reason } = structuredAgentSessionStartFailure( + { refusal, ...(wording.newSession ? { newSession: true as const } : {}) }, + structuredAgentSessionFailureWordsContext(wording.record) + ) + return { ...refusal, message: reason } +} /** What a failed acquisition proved about its process, and the outcome its operation settles to. */ -export function failedAcquisitionSettlement(error: unknown): { +export function failedAcquisitionSettlement( + error: unknown, + wording: FailedAcquisitionWording +): { exitProof: AgentSessionAcquisitionExitProof outcome: Extract } { @@ -41,8 +70,14 @@ export function failedAcquisitionSettlement(error: unknown): { : error instanceof AgentSessionAcquisitionRootExitObservedError ? 'root-exit-observed' : 'exit-proven' - const message = error instanceof Error ? error.message : String(error) + const raw = error instanceof Error ? error.message : String(error) const details = failedAcquisitionDetails(error) + const message = + failedAcquisitionRefusal(error, wording)?.refusal.message ?? + // A store refusal's message is its code, which its replay has always carried. + (isAgentSessionWireRefusalCode(raw) + ? raw + : failedAcquisitionWireRefusal(details, wording).message) return { exitProof, outcome: { @@ -80,19 +115,14 @@ function failedAcquisitionDetails( /** A failed acquisition answered as a refusal on the first call, in the shape its replay takes; * null leaves the error to the store-failure classification. */ export function failedAcquisitionRefusal( - error: unknown + error: unknown, + wording: FailedAcquisitionWording ): { ok: false; refusal: AgentSessionWireRefusal } | null { - if (error instanceof AgentSessionAcquisitionRefusal) { - return { ok: false, refusal: refuse(error.code, { reason: error.reason }, error.message) } - } // A proven exit is a settled failure, answered in the shape its ledger row replays. - if (isExitProvenAcquisitionFailure(error)) { - const details = failedAcquisitionDetails(error) + if (error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error)) { return { ok: false, - refusal: details - ? refuse('agent_session_operation_invalid', details, error.message) - : refuseUnclassified('agent_session_operation_invalid', error.message) + refusal: failedAcquisitionWireRefusal(failedAcquisitionDetails(error), wording) } } return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index c61d93cbb8f..219bac27a0c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -83,7 +83,10 @@ describe('a create that fails after its child wrote through the unbound sink', ( const failed = host.attach(CALLER, hostTestAttachParams(null)) await (cause instanceof AgentSessionPreSpawnError ? expect(failed).rejects.toThrow(EXIT_REASON) - : expect(failed).resolves.toMatchObject({ ok: false, refusal: { message: EXIT_REASON } })) + : expect(failed).resolves.toMatchObject({ + ok: false, + refusal: { message: "Codex couldn't restart." } + })) await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true 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 ad547b4ac7a..a58ae45e287 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 @@ -145,7 +145,7 @@ describe('attach', () => { refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' }, - message: 'agent_session_provider_handle_stale_fence', + message: "Codex couldn't restart.", ownerVerdict: 'exited' } } @@ -163,7 +163,7 @@ describe('attach', () => { await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ ok: false, - refusal: { message: 'commit failed', ownerVerdict: 'exited' } + refusal: { message: "Codex couldn't restart.", ownerVerdict: 'exited' } }) expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts index f42a17c310b..918b9287618 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -11,7 +11,8 @@ import { classifyStoreFailure } from './structured-agent-session-attach' import { AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRefusal + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError } from './structured-agent-session-adapter' import { failedAcquisitionRefusal, @@ -20,6 +21,11 @@ import { import { withObservedProviderExit } from './structured-agent-session-failure-text' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +const CLAUDE_CREATE = { + record: agentSessionRecordFixture(), + newSession: true +} + function replay(outcome: Parameters[0]['outcome']) { return resolveAgentSessionReplayOutcome({ operationId: 'op-1', @@ -40,8 +46,8 @@ describe('a ledger replay names the details its first answer did', () => { // Gone now, with no exit observed: no situation, on the first answer or the replay. [new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')), undefined] ])('for a failed create: %s', (error, reason) => { - const first = failedAcquisitionRefusal(error) - const replayed = replay(failedAcquisitionSettlement(error).outcome) + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) expect(first?.refusal.details?.reason).toBe(reason) expect(replayed).toMatchObject({ decision: 'refuse', @@ -54,13 +60,76 @@ describe('a ledger replay names the details its first answer did', () => { it('for a create whose cleanup could not prove the child gone', () => { const outcome = failedAcquisitionSettlement( - new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')) + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE ).outcome expect(replay(outcome)).toMatchObject({ refusal: { code: 'agent_session_ownership_unknown', details: { reason: 'ownerUnproven' } } }) }) + it.each([ + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit( + new Error('claude stream-json exited (code 1): claude: not signed in (rig)') + ) + ), + 'The provider stopped before it finished starting.' + ], + [ + new AgentSessionAcquisitionExitProvenError(new Error('spawn claude ENOENT')), + "Claude couldn't start." + ], + [ + new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'notSignedIn' + ), + 'Claude is not signed in for the selected account. Sign in, then send your message again.' + ], + [ + AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' + ), + "This conversation's history is too large to restore here. Start a new chat to continue." + ] + ])('answers and replays %s in the sentence its start failure reads as', (error, sentence) => { + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) + expect(first?.refusal.message).toBe(sentence) + expect(replayed).toMatchObject({ decision: 'refuse', refusal: { message: sentence } }) + for (const message of [ + first?.refusal.message, + failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome.message + ]) { + expect(message).not.toMatch( + /stream-json|exited \(code|ENOENT|CLAUDE_CONFIG_DIR|restore queue/ + ) + } + }) + + it('replays a spawn that failed before any process in a sentence, not its error', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionPreSpawnError(new Error('spawn claude ENOENT')), + CLAUDE_CREATE + ).outcome + expect(replay(outcome)).toMatchObject({ refusal: { message: "Claude couldn't start." } }) + }) + + it('keeps the code a store refusal replays with, and the unproven-exit marker', () => { + expect( + failedAcquisitionSettlement(new Error('agent_session_conflict'), CLAUDE_CREATE).outcome + .message + ).toBe('agent_session_conflict') + expect( + failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE + ).outcome.message + ).toBe('agent_session_acquisition_exit_unproven') + }) + it('replays the facts beside the reason, and mirrors them where released clients read', () => { const resolution = { state: 'resolved' as const, 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 711e38b29b8..352b947d376 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 @@ -378,7 +378,7 @@ describe('settled attach retry', () => { await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, - refusal: { message: 'resume rejected', ownerVerdict: 'exited' } + refusal: { message: "Codex couldn't restart.", ownerVerdict: 'exited' } }) expect(releaseAcquisition).toHaveBeenCalledTimes(1) From 96a12ea1551886f465829eec75eb9c9e3e6f5a67 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:28:44 -0700 Subject: [PATCH 091/122] fix(native-chat): show Claude's API retries as one sentence row While Claude retried a refused request (a 429, say), the chat gained one red row per attempt reading "rate_limit", with the raw retry frame behind Details. Each retry run now writes one warning row that later attempts revise in place: "Claude is rate-limited and retrying." for a rate limit (error `rate_limit` or status 429), and "Claude hit a temporary problem and is retrying." otherwise. The row carries a `providerRetrying` fact with the provider's error type and status, and the frame as a log detail capped at 512 characters. --- src/main/claude/claude-api-retry-row.test.ts | 133 ++++++++++++++++++ src/main/claude/claude-api-retry-row.ts | 39 +++++ .../claude-structured-provider-fallback.ts | 19 +++ .../agent-session-failure-words.test.ts | 5 + src/shared/agent-session-failure-words.ts | 6 +- src/shared/agent-session-failure.test.ts | 12 ++ src/shared/agent-session-failure.ts | 38 ++++- ...ctured-agent-session-dispatch-rejection.ts | 3 +- 8 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 src/main/claude/claude-api-retry-row.test.ts create mode 100644 src/main/claude/claude-api-retry-row.ts diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts new file mode 100644 index 00000000000..becadff90d3 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -0,0 +1,133 @@ +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 { + AgentJournalItemBody, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { MAX_PROVIDER_DIAGNOSTIC_CHARS } from '../../shared/agent-session-failure' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } +} + +/** A frame as Claude Code sends it while it retries a refused request. */ +function apiRetry(attempt: number, fields: Record = {}): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 622, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}`, + ...fields + } +} + +let root = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-api-retry-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function statusRowsFor(frames: Record[]) { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) + for (const frame of frames) { + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + await deferred.drained() + } + return journal + .snapshot() + .items.flatMap((item): Extract[] => + item.body.kind === 'status' ? [item.body] : [] + ) +} + +describe('a Claude api_retry frame', () => { + it('writes one sentence row per retry run, revised by each attempt, not the frame', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(3)]) + + expect(rows).toHaveLength(1) + const [row] = rows + expect(row).toMatchObject({ + text: 'Claude is rate-limited and retrying.', + tone: 'warning', + failure: { + kind: 'providerRetrying', + retry: { error: 'rate_limit', status: 429 }, + detail: { audience: 'log' } + } + }) + expect(row).not.toHaveProperty('providerFrame') + // The latest attempt's frame, as the log detail. + expect(row.failure?.detail?.text).toContain('"attempt":3') + }) + + it('starts a new row when a later run starts over', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(1)]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude is rate-limited and retrying.', + 'Claude is rate-limited and retrying.' + ]) + }) + + it('does not call a retry a rate limit unless the frame says so', async () => { + const rows = await statusRowsFor([ + apiRetry(1, { error: 'overloaded', error_status: 529 }), + apiRetry(1, { error: 'server_error', error_status: 500 }), + apiRetry(1, { error: undefined, error_status: undefined }), + apiRetry(1, { error: 'something_new', error_status: 429 }) + ]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude is rate-limited and retrying.' + ]) + expect(rows[0]?.failure).toMatchObject({ retry: { error: 'overloaded', status: 529 } }) + }) + + it('caps the frame it keeps as the detail', async () => { + const [row] = await statusRowsFor([apiRetry(1, { padding: 'x'.repeat(5_000) })]) + expect(row?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it('leaves a frame kind no one catalogued to the provider fallback, as before', async () => { + const unknown = { + type: 'system', + subtype: 'future_notice', + error: 'rate_limit', + session_id: 'provider-1', + uuid: 'future-1' + } + const rows = await statusRowsFor([unknown]) + expect(rows).toEqual([ + unhandledProviderFrameJournalItem('claude', 'message:system:future_notice', unknown)?.body + ]) + expect(rows[0]).toMatchObject({ text: 'rate_limit', tone: 'error' }) + }) +}) diff --git a/src/main/claude/claude-api-retry-row.ts b/src/main/claude/claude-api-retry-row.ts new file mode 100644 index 00000000000..195e30461a7 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.ts @@ -0,0 +1,39 @@ +// The one row a Claude `system/api_retry` frame writes: a sentence and a `providerRetrying` fact, +// revised in place for every attempt of the same retry run. + +import { + agentSessionFailureFact, + providerDiagnostic, + readProviderRetry +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' + +export const CLAUDE_API_RETRY_FRAME_KIND = 'message:system:api_retry' + +export function claudeApiRetryRowBody(message: Record): AgentJournalStatusItem { + const retry = readProviderRetry({ error: message.error, status: message.error_status }) + const words = agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + detail: providerDiagnostic(JSON.stringify(message), 'log'), + ...(retry ? { retry } : {}) + }), + { surface: 'row', agentName: TUI_AGENT_DISPLAY_NAMES.claude } + ) + return { kind: 'status', tone: 'warning', ...words } +} + +/** Numbers retry runs: a frame whose attempt does not follow the last one starts a new run. */ +export function createClaudeApiRetryRuns(): (message: Record) => number { + let run = 0 + let lastAttempt: number | null = null + return (message) => { + const attempt = typeof message.attempt === 'number' ? message.attempt : null + if (attempt === null || lastAttempt === null || attempt <= lastAttempt) { + run += 1 + } + lastAttempt = attempt + return run + } +} diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index baa6cb83a52..e107633173c 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,6 +16,11 @@ import { } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' import { rootClaudeRowStamp, type ClaudeRowStamp } from './claude-provisional-row-corrections' +import { + CLAUDE_API_RETRY_FRAME_KIND, + claudeApiRetryRowBody, + createClaudeApiRetryRuns +} from './claude-api-retry-row' export function claudeProviderFrameKind(message: Record): string { const type = claudeText(message.type) ?? 'unknown' @@ -121,9 +126,23 @@ export function createClaudeProviderFrameFallback( ) => boolean } { let sequence = 0 + const retryRun = createClaudeApiRetryRuns() return { append: (kind, payload, displayText, beforeAppend, options, stamp) => { sequence += 1 + const retrying = kind === CLAUDE_API_RETRY_FRAME_KIND ? claudeRecord(payload) : null + if (retrying) { + beforeAppend?.() + // One row per retry run, revised by each attempt, never the frame as a row. + const identity = { + provider: 'orca', + clientMessageId: `provider-retry:claude:${acquisitionId}:${retryRun(retrying)}` + } as const + const body = claudeApiRetryRowBody(retrying) + sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.publish() + return true + } const translated = unhandledProviderFrameJournalItem( 'claude', kind, diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index d229efa94ae..8dcd007628f 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -42,6 +42,11 @@ function factsFor(kind: AgentSessionFailureKind): AgentSessionFailureFact[] { refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } } ] + if (kind === 'providerRetrying') { + facts.push({ kind, retry: { error: 'rate_limit', status: 429 } }) + facts.push({ kind, retry: { error: 'overloaded', status: 529 } }) + facts.push({ kind, retry: { status: 500 } }) + } if (kind === 'attachmentInvalid') { for (const reason of AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS) { facts.push({ kind, attachment: { reason } }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 06808671c4f..cc54ba08094 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -166,7 +166,11 @@ const FAILURE_SENTENCES = { compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', cancelUnconfirmed: () => 'Cancellation was not confirmed.', answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', - hostFault: () => "Orca ran into a problem, so this didn't go through. Try again." + hostFault: () => "Orca ran into a problem, so this didn't go through. Try again.", + providerRetrying: ({ agentName }, { retry }) => + retry?.error === 'rate_limit' || retry?.status === 429 + ? `${agentName ?? 'The agent'} is rate-limited and retrying.` + : `${agentName ?? 'The agent'} hit a temporary problem and is retrying.` } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index 9dde30aff4e..a15467b6a31 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -52,6 +52,18 @@ describe('reading a failure fact', () => { }) }) + it('keeps what a provider said it is retrying, and only that', () => { + expect( + readAgentSessionFailureFact({ + kind: 'providerRetrying', + retry: { error: 'rate_limit', status: 429, attempt: 3 } + }) + ).toEqual({ kind: 'providerRetrying', retry: { error: 'rate_limit', status: 429 } }) + expect( + readAgentSessionFailureFact({ kind: 'providerRetrying', retry: { error: '', status: 'x' } }) + ).toEqual({ kind: 'providerRetrying' }) + }) + it('reads a row an unreleased build wrote with a cause as a refusal with no details', () => { expect( readAgentSessionFailureFact({ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b809f7dd489..e753d98dc23 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -35,7 +35,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'compactionUnconfirmed', 'cancelUnconfirmed', 'answerUnconfirmed', - 'hostFault' + 'hostFault', + /** The provider is retrying a request its API refused; not a failure yet. */ + 'providerRetrying' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] @@ -75,6 +77,14 @@ export type AgentSessionAttachmentProblem = { limit?: number } +/** What the provider said it is retrying, in its own fields. */ +export type AgentSessionProviderRetry = { + /** The provider's error type, e.g. `rate_limit` or `overloaded`. */ + error?: string + /** The HTTP status the request failed with. */ + status?: number +} + export type AgentSessionFailureFact = { kind: AgentSessionFailureKind /** Provider-authored only; absent whenever Orca wrote the words. */ @@ -83,6 +93,8 @@ export type AgentSessionFailureFact = { refusal?: AgentSessionRefusalReference /** On `attachmentInvalid`: which check the attachment failed. */ attachment?: AgentSessionAttachmentProblem + /** On `providerRetrying`: why the provider is retrying. */ + retry?: AgentSessionProviderRetry } /** Null for empty text, so a writer never records a detail with nothing in it. */ @@ -100,6 +112,7 @@ export function agentSessionFailureFact( detail?: ProviderDiagnostic refusal?: AgentSessionRefusalReference attachment?: AgentSessionAttachmentProblem + retry?: AgentSessionProviderRetry } = {} ): AgentSessionFailureFact { // Re-bounded here, so no writer can store more than the cap however it built the detail. @@ -110,7 +123,8 @@ export function agentSessionFailureFact( kind, ...(detail ? { detail } : {}), ...(extra.refusal ? { refusal: extra.refusal } : {}), - ...(extra.attachment ? { attachment: extra.attachment } : {}) + ...(extra.attachment ? { attachment: extra.attachment } : {}), + ...(extra.retry ? { retry: extra.retry } : {}) } } @@ -140,6 +154,22 @@ function readAttachmentProblem(value: unknown): AgentSessionAttachmentProblem | : { reason } } +/** A retry as a reader meets it; undefined when it names neither field. */ +export function readProviderRetry(value: unknown): AgentSessionProviderRetry | undefined { + if (!isRecord(value)) { + return undefined + } + const error = + typeof value.error === 'string' && value.error.trim() ? value.error.trim() : undefined + const status = + typeof value.status === 'number' && Number.isInteger(value.status) && value.status > 0 + ? value.status + : undefined + return error || status + ? { ...(error ? { error } : {}), ...(status ? { status } : {}) } + : undefined +} + /** A fact as a reader meets it. Undefined for anything this build cannot place, including a kind a * newer host added, so the reader falls back to what it does for a row with no fact. */ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined { @@ -148,10 +178,12 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure } const refusal = readAgentSessionRefusalReference(value.refusal) const attachment = readAttachmentProblem(value.attachment) + const retry = readProviderRetry(value.retry) return agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), ...(refusal ? { refusal } : {}), - ...(attachment ? { attachment } : {}) + ...(attachment ? { attachment } : {}), + ...(retry ? { retry } : {}) }) } diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index ef3cebeeeaf..7fe0992ba73 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -93,7 +93,8 @@ const KIND_CATEGORY = { compactionFailed: 'transport', compactionUnconfirmed: 'transport', cancelUnconfirmed: 'transport', - answerUnconfirmed: 'transport' + answerUnconfirmed: 'transport', + providerRetrying: 'transport' } satisfies Record const NO_FAILURE_KINDS: ReadonlySet = new Set([ From d2ef1b61ba8953ec6b39d8dacff16eea342341c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:31:08 -0700 Subject: [PATCH 092/122] fix(native-chat): tell the user to run /clear again when its new conversation can't start When /clear's replacement conversation failed to start, the result told the user to "send your message again", which would go into the old conversation. The failure words now take the command the start was for, so a failed /clear reads "Codex is not signed in for the selected account. Sign in, then run /clear again.", "Codex couldn't start. Run /clear again." or "The provider stopped before it finished starting. Run /clear again." A message send keeps its wording. --- .../structured-conversation-command.test.ts | 12 +++++++-- .../structured-conversation-command.ts | 2 +- .../agent-session-failure-words.test.ts | 26 ++++++++++++++++--- src/shared/agent-session-failure-words.ts | 25 ++++++++++++++---- 4 files changed, 54 insertions(+), 11 deletions(-) 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 52c46f4d828..0b65d852d51 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 @@ -290,6 +290,14 @@ describe('host conversation commands', () => { }) }) + it('tells the user to run /clear again when the replacement could not start', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce(new Error('spawn codex ENOENT')) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { state: 'completed', error: "Codex couldn't start. Run /clear again." } + }) + }) + it('keeps the situation a refused replacement start named', async () => { vi.mocked(adapter.acquire).mockRejectedValueOnce( new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') @@ -299,8 +307,8 @@ describe('host conversation commands', () => { value: { state: 'completed', replacementSessionId: undefined, - error: - 'Codex is not signed in for the selected account. Sign in, then send your message again.', + // The next step is the command the user ran, not a message into the old conversation. + error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', failure: { kind: 'notSignedIn' } } }) 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 e49dc40d7a6..b8d8da2c7b9 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 @@ -200,7 +200,7 @@ export function runStructuredConversationCommand( refusal: acquired.refusal, newSession: true }), - structuredAgentSessionFailureWordsContext(record) + { ...structuredAgentSessionFailureWordsContext(record), command: 'clear' } ) } await store.setConversationCommand(sessionId, ctx.fence, failed) diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 8dcd007628f..a19bf204ff7 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -60,15 +60,19 @@ describe('the words written beside a failure fact', () => { describe.each(SURFACES)('on the %s surface', (surface) => { it.each(AGENT_SESSION_FAILURE_KINDS)('for %s are one sentence a person can read', (kind) => { for (const fact of factsFor(kind)) { - for (const agentName of ['Claude', undefined]) { - const sentence = agentSessionFailureSentence(fact, surface, { agentName }) + for (const [agentName, command] of [ + ['Claude', undefined], + [undefined, undefined], + ['Codex', 'clear'] + ] as const) { + const sentence = agentSessionFailureSentence(fact, surface, { agentName, command }) expect([fact, sentence]).toEqual([fact, expect.stringMatching(/[^.]\.$/)]) expect(sentence).not.toMatch(/\.\./) expect(sentence).not.toMatch(ORCA_INTERNAL) if (surface === 'rejection' && LEGACY_MARKER_KINDS.has(kind)) { continue } - const context = { agentName, provider: 'claude' } as const + const context = { agentName, command, provider: 'claude' } as const const written = surface === 'row' ? agentSessionFailureWords(fact, { ...context, surface }).text @@ -104,6 +108,22 @@ describe('the words written beside a failure fact', () => { } }) + it('names /clear as the next step for the start a /clear needed', () => { + const clear = (kind: AgentSessionFailureKind) => + agentSessionFailureSentence({ kind }, 'row', { agentName: 'Codex', command: 'clear' }) + expect(clear('notSignedIn')).toBe( + 'Codex is not signed in for the selected account. Sign in, then run /clear again.' + ) + expect(clear('startFailed')).toBe("Codex couldn't start. Run /clear again.") + expect(clear('providerStartFailed')).toBe( + 'The provider stopped before it finished starting. Run /clear again.' + ) + // A send keeps its own next step. + expect( + agentSessionFailureSentence({ kind: 'startFailed' }, 'row', { agentName: 'Codex' }) + ).toBe("Codex couldn't start.") + }) + it('names the exit a row reports differently from the message it left unsent', () => { expect(agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row' }).text).toBe( 'The provider stopped while this response was in progress. You can continue in this conversation.' diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index cc54ba08094..9dbce62831f 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -47,6 +47,9 @@ export type AgentSessionFailureWordsContext = { agentName?: string /** Names the legacy queue-full marker; without it a full queue is worded as a sentence. */ provider?: 'claude' | 'codex' + /** The conversation command a failed start was for, so the next step is to run it again + * rather than to send a message. */ + command?: 'clear' } /** @@ -93,8 +96,19 @@ function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefine return quoted ? `${lead}: ${quoted}.` : `${lead}.` } +/** What to do once the start can work, for a sentence that ends in it. */ +function retryStep({ command }: AgentSessionFailureWordsContext): string { + return command === 'clear' ? 'run /clear again' : 'send your message again' +} + +/** The next step a start failure with none of its own leaves: a command, run again. */ +function commandRetry({ command }: AgentSessionFailureWordsContext): string { + return command === 'clear' ? ' Run /clear again.' : '' +} + function couldNot(verb: string): Sentence { - return ({ agentName }, fact) => { + return (context, fact) => { + const { agentName } = context const failed = `${agentName ?? 'The agent'} couldn't ${verb}.` // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. if (fact.refusal?.details?.reason === 'claimConflicted') { @@ -103,7 +117,7 @@ function couldNot(verb: string): Sentence { const code = fact.refusal?.code return code && !START_REFUSAL_RESUMABLE[code] ? `${failed} Start a new chat to continue.` - : failed + : `${failed}${commandRetry(context)}` } } @@ -137,10 +151,11 @@ const ATTACHMENT_SENTENCES = { > const FAILURE_SENTENCES = { - providerStartFailed: () => 'The provider stopped before it finished starting.', + providerStartFailed: (context) => + `The provider stopped before it finished starting.${commandRetry(context)}`, startFailed: couldNot('start'), - notSignedIn: ({ agentName }) => - `${agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then send your message again.`, + notSignedIn: (context) => + `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, historyTooLarge: () => "This conversation's history is too large to restore here. Start a new chat to continue.", providerExited: (_, __, surface) => From e5d73cd462fbb773bfc4dc0917ea7eaf2ca3dbf5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:35:33 -0700 Subject: [PATCH 093/122] test(native-chat): expect a failed Claude create to be refused in a sentence The runtime suites asserted the CLI's stderr reached the create refusal; it now goes to the log and the refusal reads as the chat's start failure. --- .../runtime/claude-structured-failed-start-resume.test.ts | 7 +++++-- .../runtime/claude-structured-session-integration.test.ts | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index 32362cbafdc..59de378ad92 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -55,7 +55,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { // Before the spawn returns, the start time of a dead pid is unreadable; during that read, the // child is found closed afterwards. Both must answer with what the CLI said. it.each(['spawn', 'start-time-read'] as const)( - "refuses the create with the CLI's own diagnostic when it exits at %s", + "refuses the create in a sentence, not the CLI's diagnostic, when it exits at %s", async (at) => { const diagnostic = 'claude stream-json exited (code 1): claude: not signed in' claude.behave(SESSION, { exitsDuringSpawn: { diagnostic, at } }) @@ -65,7 +65,10 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { expect(created).toMatchObject({ ok: false, - refusal: { message: expect.stringContaining('not signed in'), ownerVerdict: 'exited' } + refusal: { + message: 'The provider stopped before it finished starting.', + ownerVerdict: 'exited' + } }) } ) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 5e7fb1623b0..efeedc198bf 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -393,7 +393,7 @@ describe('a structured Claude session over agentSession.*', () => { // The root's death is first-hand. Its descendants were never snapshottable, or one was seen // alive; either way the lease follows the root, so the reservation goes with it. it.each(['unverifiable', 'live'] as const)( - 'releases a session whose CLI self-exited during create with its tree %s, with its diagnostic intact', + 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', async (tree) => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', @@ -409,7 +409,8 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), + // The CLI's stderr is log text; the person reads what the chat's start failure says. + message: 'The provider stopped before it finished starting.', ownerVerdict: 'exited' } } @@ -444,7 +445,7 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), + message: 'The provider stopped before it finished starting.', ownerVerdict: 'exited' } } From f160988f43b572519538171459fdd92b5c7e5b6a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:54:24 -0700 Subject: [PATCH 094/122] fix(native-chat): name the agent that stopped starting and say how to retry a failed start A start the provider ended now reads "Claude stopped before it finished starting." (or "The agent ..." when the chat's agent is unknown) instead of naming "the provider". A start or restart that failed with a chat left to retry now ends in "Send your message to try again.", or "Run /clear again." for /clear; released clients print only this sentence. A message rejected at dispatch because its child died while starting names the same agent as the start's row. --- ...-agent-session-accept-then-deliver.test.ts | 6 ++-- ...-agent-session-acquisition-options.test.ts | 4 +-- ...ured-agent-session-append-delivery.test.ts | 4 +-- ...session-dead-generation-settlement.test.ts | 2 +- .../structured-agent-session-delivery-loop.ts | 3 +- ...ession-failed-create-owner-verdict.test.ts | 4 +-- ...session-failed-create-sink-release.test.ts | 2 +- ...uctured-agent-session-failure-text.test.ts | 15 +++++---- .../structured-agent-session-host.test.ts | 7 +++-- ...gent-session-provider-child-record.test.ts | 2 +- ...ured-agent-session-refusal-details.test.ts | 8 +++-- ...red-agent-session-send-preparation.test.ts | 8 ++--- ...session-send-restarts-failed-start.test.ts | 8 ++--- ...agent-session-settled-attach-retry.test.ts | 5 ++- ...agent-session-startup-failure-exit.test.ts | 2 +- .../structured-agent-session-turns.ts | 15 +++++++-- ...sion-view-start-after-failed-start.test.ts | 2 +- ...ude-structured-failed-start-resume.test.ts | 2 +- ...e-structured-resumed-start-failure.test.ts | 2 +- ...e-structured-send-held-for-startup.test.ts | 2 +- ...-send-restart-dies-before-dispatch.test.ts | 2 +- ...ude-structured-session-integration.test.ts | 4 +-- .../agent-session-failure-words.test.ts | 31 ++++++++++++++++--- src/shared/agent-session-failure-words.ts | 9 ++++-- 24 files changed, 98 insertions(+), 51 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 c248468d779..e85d9dd0b11 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 @@ -318,7 +318,7 @@ describe('a start the chat needed and did not get', () => { expect(rows).toHaveLength(1) // Orca's spawn error goes to the log; the row and every message say what failed, typed. The // child is gone, but no exit was observed, so nothing blames the provider. - expect(rows[0]).toBe("Codex couldn't restart.") + expect(rows[0]).toBe("Codex couldn't restart. Send your message to try again.") const failure = { kind: 'restartFailed', refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } @@ -358,7 +358,7 @@ describe('a start the chat needed and did not get', () => { 'spawn', () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), { - text: "Codex couldn't restart.", + text: "Codex couldn't restart. Send your message to try again.", failure: { kind: 'restartFailed', refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } @@ -373,7 +373,7 @@ describe('a start the chat needed and did not get', () => { ), { // No process ever started, so nothing says the provider stopped. - text: "Codex couldn't restart.", + text: "Codex couldn't restart. Send your message to try again.", failure: { kind: 'restartFailed', refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } 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 93e1663b76d..7c12909e036 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 @@ -432,7 +432,7 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: "Codex couldn't restart." + message: "Codex couldn't restart. Send your message to try again." } }) expect(releaseAcquisition).toHaveBeenCalledOnce() @@ -532,7 +532,7 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: "Codex couldn't restart." + message: "Codex couldn't restart. Send your message to try again." } }) : expect(failed).rejects.toThrow( 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 61414d67810..8d9dbbca07d 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 @@ -166,9 +166,7 @@ describe('an open chat receives every row its journal commits', () => { ) ) // One row, however many of its writers reported the start. - expect(pane.received().statusRows).toEqual([ - 'The provider stopped before it finished starting.' - ]) + expect(pane.received().statusRows).toEqual(['Codex stopped before it finished starting.']) }) it('shows a revision the provider queued with no publish behind it', async () => { 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 dd5a4539616..093c54cf2d8 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 @@ -320,7 +320,7 @@ describe('dead structured-session generation settlement', () => { expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', - reason: 'The provider stopped before it finished starting.', + reason: 'The agent stopped before it finished starting.', rejection: { kind: 'providerStartFailed', detail: { text: 'code 1\nnot signed in', audience: 'log' } 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 89a52007f4d..9f513cecee8 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 @@ -202,7 +202,8 @@ export class StructuredAgentSessionDeliveryLoop { journal: session.journal, fence: awaitedChild.fence, adapter: this.deps.adapter, - providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase + providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, + failureTextContext: this.deps.failureTextContext(sessionId) }, next ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 0648c610575..63e65d67e78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -25,8 +25,8 @@ import { const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' // The refusal says what the chat's start failure says; the error text stays in the log. -const COULD_NOT_RESTART = "Codex couldn't restart." -const PROVIDER_STOPPED = 'The provider stopped before it finished starting.' +const COULD_NOT_RESTART = "Codex couldn't restart. Send your message to try again." +const PROVIDER_STOPPED = 'Codex stopped before it finished starting.' let root: string let store: AgentSessionRecordStore diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 219bac27a0c..8dad396d4d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -85,7 +85,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( ? expect(failed).rejects.toThrow(EXIT_REASON) : expect(failed).resolves.toMatchObject({ ok: false, - refusal: { message: "Codex couldn't restart." } + refusal: { message: "Codex couldn't restart. Send your message to try again." } })) await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index c33e8f9af43..d2d43913ca1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -28,7 +28,7 @@ describe('structuredAgentSessionStartFailure', () => { ) expect(structuredAgentSessionStartFailure({ error: carried }, { agentName: 'Claude' })).toEqual( { - reason: "Claude couldn't start.", + reason: "Claude couldn't start. Send your message to try again.", rejection: { kind: 'startFailed', detail: { text: 'code 1\nboom', audience: 'log' } } } ) @@ -65,7 +65,7 @@ describe('structuredAgentSessionStartFailure', () => { { agentName: 'Claude' } ) expect(words).toEqual({ - reason: "Claude couldn't restart.", + reason: "Claude couldn't restart. Send your message to try again.", rejection: { kind: 'restartFailed', refusal: { @@ -118,7 +118,7 @@ describe('structuredAgentSessionStartFailure', () => { { agentName: 'Codex' } ) ).toEqual({ - reason: "Codex couldn't restart.", + reason: "Codex couldn't restart. Send your message to try again.", rejection: { kind: 'restartFailed', detail: person, @@ -139,13 +139,16 @@ describe('structuredAgentSessionStartFailure', () => { diagnostic: { text: 'code 1', audience: 'log' } }) ).toEqual({ - reason: 'The provider stopped before it finished starting.', + reason: 'The agent stopped before it finished starting.', rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } }) // A start that threw proves nothing about the provider: it may be Orca's, or a failed spawn. expect( structuredAgentSessionStartFailure({ error: new Error('spawn claude ENOENT') }, {}) - ).toEqual({ reason: "The agent couldn't start.", rejection: { kind: 'startFailed' } }) + ).toEqual({ + reason: "The agent couldn't start. Send your message to try again.", + rejection: { kind: 'startFailed' } + }) // The same error, once the adapter that observed the child's exit marked it, blames the provider. const exit = withObservedProviderExit( withProviderDiagnostic(new Error('exited (code 1)'), providerDiagnostic('code 1', 'log')) @@ -153,7 +156,7 @@ describe('structuredAgentSessionStartFailure', () => { expect( structuredAgentSessionStartFailure({ error: new Error('wrapped', { cause: exit }) }) ).toEqual({ - reason: 'The provider stopped before it finished starting.', + reason: 'The agent stopped before it finished starting.', rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } }) }) 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 a58ae45e287..9392799bab4 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 @@ -145,7 +145,7 @@ describe('attach', () => { refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' }, - message: "Codex couldn't restart.", + message: "Codex couldn't restart. Send your message to try again.", ownerVerdict: 'exited' } } @@ -163,7 +163,10 @@ describe('attach', () => { await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ ok: false, - refusal: { message: "Codex couldn't restart.", ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) 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 75219c8f152..252869b6b61 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 @@ -366,7 +366,7 @@ describe('settling an earlier child before the next one takes its message', () = }) const START_EXIT = 'claude stream-json exited (code 1)' -const START_TEXT = 'The provider stopped before it finished starting.' +const START_TEXT = 'Codex stopped before it finished starting.' const START_FAILURE: AgentSessionFailureFact = { kind: 'providerStartFailed', detail: { text: START_EXIT, audience: 'log' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts index 918b9287618..485f7ca67e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -75,11 +75,11 @@ describe('a ledger replay names the details its first answer did', () => { new Error('claude stream-json exited (code 1): claude: not signed in (rig)') ) ), - 'The provider stopped before it finished starting.' + 'Claude stopped before it finished starting.' ], [ new AgentSessionAcquisitionExitProvenError(new Error('spawn claude ENOENT')), - "Claude couldn't start." + "Claude couldn't start. Send your message to try again." ], [ new AgentSessionAcquisitionRefusal( @@ -114,7 +114,9 @@ describe('a ledger replay names the details its first answer did', () => { new AgentSessionPreSpawnError(new Error('spawn claude ENOENT')), CLAUDE_CREATE ).outcome - expect(replay(outcome)).toMatchObject({ refusal: { message: "Claude couldn't start." } }) + expect(replay(outcome)).toMatchObject({ + refusal: { message: "Claude couldn't start. Send your message to try again." } + }) }) it('keeps the code a store refusal replays with, and the unproven-exit marker', () => { 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 fa63c855b3f..84d63503cc1 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 @@ -427,7 +427,7 @@ describe('a send with no live owner', () => { const params = sendParams('while signed out') // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit // was observed, so the chat does not say the provider stopped. - const cause = "Codex couldn't restart." + const cause = "Codex couldn't restart. Send your message to try again." const id = await accept(params) @@ -463,10 +463,10 @@ describe('a send with no live owner', () => { } expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - reason: "Codex couldn't restart.", + reason: "Codex couldn't restart. Send your message to try again.", rejection }) - expect(errorStatuses()).toEqual(["Codex couldn't restart."]) + expect(errorStatuses()).toEqual(["Codex couldn't restart. Send your message to try again."]) // Orca's own text is logged once where the start failed. expect(warn).toHaveBeenCalledWith( '[agent-session] provider start failed:', @@ -551,7 +551,7 @@ describe('a send with no live owner', () => { const id = await accept(sendParams('owner being settled')) // The refusal's prose stays out of the chat; its code rides in the fact. - const cause = "Codex couldn't restart." + const cause = "Codex couldn't restart. Send your message to try again." expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause, 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 a087ccf9d1b..23f4f4a1111 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 @@ -203,7 +203,7 @@ describe('a send into a published session whose child ended before startup', () // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. expect(submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: 'The provider stopped before it finished starting.', + reason: 'Codex stopped before it finished starting.', rejection: STARTUP_FAILURE, recovered: true }) @@ -211,7 +211,7 @@ describe('a send into a published session whose child ended before startup', () host.journalSnapshot(SESSION).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) expect(journalStatuses().slice(rowsBefore)).toEqual([ - 'The provider stopped before it finished starting.' + 'Codex stopped before it finished starting.' ]) // The failed restart moved the fence twice: the acquisition, and the exit that released it. expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) @@ -248,12 +248,12 @@ describe('a send while the child of the first start is still proving itself', () expect(submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: 'The provider stopped before it finished starting.', + reason: 'Codex stopped before it finished starting.', rejection: STARTUP_FAILURE, recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual(['The provider stopped before it finished starting.']) + expect(journalStatuses()).toEqual(['Codex stopped before it finished starting.']) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) 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 352b947d376..fa4a4434f8b 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 @@ -378,7 +378,10 @@ describe('settled attach retry', () => { await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, - refusal: { message: "Codex couldn't restart.", ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 9cf76ee8448..201a875b531 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 @@ -13,7 +13,7 @@ import { const SESSION = 'session-1' const GENERATION = 'generation-1' const REASON = 'claude stream-json exited (code 1): session limit reached' -const STARTUP_TEXT = 'The provider stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting.' function startedSession(): StructuredAgentSessionUnexpectedExitSession & { journal: { appendLifecycleBatch: ReturnType } 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 2b755742f8d..49642b9a897 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 @@ -7,7 +7,10 @@ // turn the provider already accepted. import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalStatusItem, @@ -84,7 +87,10 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - return { state: 'rejected', ...structuredAgentSessionStartFailure({ error }) } + return { + state: 'rejected', + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -148,7 +154,10 @@ export async function performSend( export type AgentSessionHandoverContext = Pick< AgentSessionTurnContext, 'sessionId' | 'journal' | 'fence' | 'adapter' | 'providerChildPhase' -> +> & { + /** Who a start failure met at dispatch names, as the start's own row does. */ + failureTextContext?: AgentSessionFailureWordsContext +} /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash 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 22630c052ab..c65b65622e9 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 @@ -181,7 +181,7 @@ describe('a fresh chat whose Claude start fails', () => { } await settleExits() // The row says the provider stopped; its stderr stays out of the sentence. - const startFailure = 'The provider stopped before it finished starting.' + const startFailure = 'Claude stopped before it finished starting.' // Opening the chat: the create's start, once, and its row. expect(claude.connections).toHaveLength(1) expect(timeline()).toEqual([startFailure]) diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index 59de378ad92..67bbed97de3 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -66,7 +66,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { expect(created).toMatchObject({ ok: false, refusal: { - message: 'The provider stopped before it finished starting.', + message: 'Claude stopped before it finished starting.', ownerVerdict: 'exited' } }) diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 45ec8bc4a6b..97629b5100c 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -15,7 +15,7 @@ import { const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in' -const STARTUP_TEXT = 'The provider stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting.' let claude = createScriptedClaudeRuntime([SESSION]) 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 3564c97284f..26f3a84f513 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 @@ -18,7 +18,7 @@ import { const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' -const STARTUP_TEXT = 'The provider stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting.' let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 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 f5193fe8eb8..a9f1d3ab7e1 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 @@ -19,7 +19,7 @@ import { 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)' -const STARTUP_TEXT = 'The provider stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting.' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => void): Promise { diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index efeedc198bf..45311013fb2 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -410,7 +410,7 @@ describe('a structured Claude session over agentSession.*', () => { refusal: { code: 'agent_session_operation_invalid', // The CLI's stderr is log text; the person reads what the chat's start failure says. - message: 'The provider stopped before it finished starting.', + message: 'Claude stopped before it finished starting.', ownerVerdict: 'exited' } } @@ -445,7 +445,7 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'The provider stopped before it finished starting.', + message: 'Claude stopped before it finished starting.', ownerVerdict: 'exited' } } diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index a19bf204ff7..b4d3b0aff32 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -115,13 +115,36 @@ describe('the words written beside a failure fact', () => { 'Codex is not signed in for the selected account. Sign in, then run /clear again.' ) expect(clear('startFailed')).toBe("Codex couldn't start. Run /clear again.") + expect(clear('restartFailed')).toBe("Codex couldn't restart. Run /clear again.") expect(clear('providerStartFailed')).toBe( - 'The provider stopped before it finished starting. Run /clear again.' + 'Codex stopped before it finished starting. Run /clear again.' ) - // A send keeps its own next step. + }) + + it('names the agent that stopped starting, and a failed start or restart gives a next step', () => { + const sentence = (kind: AgentSessionFailureKind, agentName?: string) => + agentSessionFailureSentence({ kind }, 'rejection', { agentName }) + expect(sentence('providerStartFailed', 'Claude')).toBe( + 'Claude stopped before it finished starting.' + ) + expect(sentence('providerStartFailed')).toBe('The agent stopped before it finished starting.') + expect(sentence('startFailed', 'Codex')).toBe( + "Codex couldn't start. Send your message to try again." + ) + expect(sentence('restartFailed')).toBe( + "The agent couldn't restart. Send your message to try again." + ) + // Nothing to restart from: a new chat is the only step, so no retry is offered. expect( - agentSessionFailureSentence({ kind: 'startFailed' }, 'row', { agentName: 'Codex' }) - ).toBe("Codex couldn't start.") + agentSessionFailureSentence( + { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + }, + 'row', + { agentName: 'Codex' } + ) + ).toBe("Codex couldn't start. Start a new chat to continue.") }) it('names the exit a row reports differently from the message it left unsent', () => { diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 9dbce62831f..25e0e7d16da 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -106,6 +106,11 @@ function commandRetry({ command }: AgentSessionFailureWordsContext): string { return command === 'clear' ? ' Run /clear again.' : '' } +/** The next step after a start or restart that failed: the command, or the message, again. */ +function startRetry({ command }: AgentSessionFailureWordsContext): string { + return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' +} + function couldNot(verb: string): Sentence { return (context, fact) => { const { agentName } = context @@ -117,7 +122,7 @@ function couldNot(verb: string): Sentence { const code = fact.refusal?.code return code && !START_REFUSAL_RESUMABLE[code] ? `${failed} Start a new chat to continue.` - : `${failed}${commandRetry(context)}` + : `${failed}${startRetry(context)}` } } @@ -152,7 +157,7 @@ const ATTACHMENT_SENTENCES = { const FAILURE_SENTENCES = { providerStartFailed: (context) => - `The provider stopped before it finished starting.${commandRetry(context)}`, + `${context.agentName ?? 'The agent'} stopped before it finished starting.${commandRetry(context)}`, startFailed: couldNot('start'), notSignedIn: (context) => `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, 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 095/122] 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 2db9fd24890..f208e6cb4a9 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 91fc1899d1d..00000000000 --- 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 9709cd30a68..00000000000 --- 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 096/122] 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 334d4ef18b6..a8b9f01b20b 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)) }) From 0a50e3f4fa7be17ec82f74732c37e1f2291dfd23 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:25:50 -0700 Subject: [PATCH 097/122] fix(native-chat): answer a create refused before spawn in the words its replay reads A create that failed before any process started threw Orca's own error text as its first answer, while its replay from the operation ledger read the generic start sentence. The two refusals a person can act on, a launch whose Anthropic sign-in variables override the managed Claude account and a Claude account switch in progress, are now typed where they are thrown and worded by the shared failure constructor, so the first answer and the replay say the same thing. Every other pre-spawn failure reads the generic start sentence, with its own text in the log. The first answer keeps its wire code; a message that is itself a code is unchanged. --- .../claude-structured-auth-parity.test.ts | 25 ++- .../claude-structured-launch-resolution.ts | 8 +- .../claude-structured-session-acquisition.ts | 4 +- .../structured-agent-session-adapter.ts | 17 +- .../structured-agent-session-attach-flow.ts | 10 +- ...red-agent-session-failed-create-refusal.ts | 16 ++ ...session-failed-create-sink-release.test.ts | 4 +- ...uctured-agent-session-failure-text.test.ts | 19 ++ .../structured-agent-session-failure-text.ts | 32 +++- ...ent-session-pre-spawn-first-answer.test.ts | 179 ++++++++++++++++++ ...nt-session-processless-reservation.test.ts | 6 +- ...ude-structured-session-integration.test.ts | 25 ++- src/shared/agent-session-failure-words.ts | 4 + src/shared/agent-session-failure.ts | 2 + src/shared/agent-session-refusal-details.ts | 3 + ...ctured-agent-session-dispatch-rejection.ts | 2 + 16 files changed, 328 insertions(+), 28 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index ddc69366aad..002240e4dd7 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -105,7 +105,10 @@ describe('claude structured auth parity with the terminal preflight', () => { resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { @@ -114,7 +117,10 @@ describe('claude structured auth parity with the terminal preflight', () => { stripAuthEnv: true, overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('still admits a non-auth env overlay under a managed account', async () => { @@ -161,7 +167,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) }) it('waits a settling account switch out rather than refusing a resolved launch', async () => { @@ -183,7 +192,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // Nothing was spawned, so the refusal must not have opened a connection. expect(claude.connections).toHaveLength(0) }) @@ -227,7 +239,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // No replacement child was opened, so nothing is left running unowned. expect(claude.connections).toHaveLength(1) await adapter.closeAll() diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a2b3d41bbfd..30ad2212e46 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -172,7 +172,9 @@ export async function resolveClaudeStructuredInvocation( // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE), { + reason: 'managedAccountEnvOverride' + }) } // Why the overlay merges onto the inherited env rather than replacing it: the child // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath @@ -209,7 +211,9 @@ export async function assertClaudeAuthSwitchSettled( timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS ): Promise { if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 5e2dff4b0cf..372e1f8eb94 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -59,7 +59,9 @@ export async function acquireClaudeSession({ // A managed-account switch is mid-swap of the pinned credential home; refuse here, // before this acquisition cancels the previous attempt and closes the live session. if (isClaudeAuthSwitchInProgress()) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() 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 8249241b4f2..90c311380c0 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 @@ -117,11 +117,24 @@ export type AgentSessionAcquisition = { providerChildPhase?: StructuredAgentSessionProviderChildPhase } +/** A refusal before spawn that a person can act on; the site that refused names it. */ +export type AgentSessionPreSpawnReason = Extract< + AgentSessionRefusalReason<'agent_session_operation_invalid'>, + 'managedAccountEnvOverride' | 'accountSwitchInProgress' +> + /** Acquisition failed with first-hand proof that no provider process existed. */ export class AgentSessionPreSpawnError extends Error { - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause), { cause }) + /** Absent: Orca's own reason, which only the log reads. A wrapped pre-spawn error keeps its. */ + readonly reason: AgentSessionPreSpawnReason | undefined + + constructor( + cause: unknown, + options: { reason?: AgentSessionPreSpawnReason; message?: string } = {} + ) { + super(options.message ?? (cause instanceof Error ? cause.message : String(cause)), { cause }) this.name = 'AgentSessionPreSpawnError' + this.reason = options.reason ?? (isAgentSessionPreSpawnError(cause) ? cause.reason : undefined) } } 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 ebe6009320d..8fdee5c62bd 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 @@ -2,7 +2,8 @@ import { refuse } from '../../../shared/agent-session-wire-refusals' import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' import { failedAcquisitionRefusal, - failedAcquisitionSettlement + failedAcquisitionSettlement, + preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' import type { StructuredAgentSessionAdapter, @@ -203,15 +204,16 @@ export async function performAttach( } input.onAcquisitionFailed?.(error) const failed = failedAcquisitionRefusal(error, wording) - if (failed) { - // The refusal carries only its sentence, so what failed is kept here. + const thrown = failed ? error : preSpawnFailureInWords(error, wording) + if (failed || thrown !== error) { + // The answer carries only its sentence, so what failed is kept here. console.warn('[agent-session] provider start failed:', error) } return ( failed ?? { ok: false, refusal: classifyStoreFailure( - error, + thrown, store.getRecord(sessionId)?.lease.runtimeFence ?? null, store.getRecord(sessionId) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index db3520a6323..90f52db3d09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -20,6 +20,7 @@ import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError, isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { @@ -106,6 +107,9 @@ function failedAcquisitionDetails( if (error instanceof AgentSessionAcquisitionRefusal) { return { reason: error.reason } } + if (isAgentSessionPreSpawnError(error) && error.reason) { + return { reason: error.reason } + } if (isExitProvenAcquisitionFailure(error) && providerExitObserved(error)) { return { reason: 'providerStartFailed' } } @@ -128,6 +132,18 @@ export function failedAcquisitionRefusal( return null } +/** A start that failed before any process still throws, in the sentence its replay reads: the + * error's own text is Orca's or the refusing site's, so it goes to the log. A message that is + * itself a code keeps it, since the wire routes on it. */ +export function preSpawnFailureInWords(error: unknown, wording: FailedAcquisitionWording): unknown { + if (!isAgentSessionPreSpawnError(error) || /^[a-z0-9_]+$/.test(error.message)) { + return error + } + return new AgentSessionPreSpawnError(error, { + message: failedAcquisitionSettlement(error, wording).outcome.message + }) +} + /** Only a durably failed operation says anything about retrying under a new one. */ export function failedCreateRefusal( refusal: AgentSessionWireRefusal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 8dad396d4d1..69f9b95c03a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -82,7 +82,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( const failed = host.attach(CALLER, hostTestAttachParams(null)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") : expect(failed).resolves.toMatchObject({ ok: false, refusal: { message: "Codex couldn't restart. Send your message to try again." } @@ -121,7 +121,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( // The session stays indexed across this failure: it is a resume, not a create. const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") : expect(failed).resolves.toMatchObject({ ok: false })) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index d2d43913ca1..d14de6b2e1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -53,6 +53,25 @@ describe('structuredAgentSessionStartFailure', () => { ) }) + it.each([ + [ + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ] + ] as const)('words a start refused for %s by that situation', (reason, sentence) => { + const code = 'agent_session_operation_invalid' + expect( + structuredAgentSessionStartFailure( + { refusal: refuse(code, { reason }, code) }, + { agentName: 'Claude' } + ) + ).toEqual({ reason: sentence, rejection: { kind: reason } }) + }) + it('words a refused restart by its situation, never its message', () => { const words = structuredAgentSessionStartFailure( { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 77088ef7e5f..6d2c033299a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -5,8 +5,10 @@ import { agentSessionFailureFact, providerDiagnosticOf, type AgentSessionFailureFact, + type AgentSessionFailureKind, type ProviderDiagnostic } from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' import { agentSessionFailureWords, type AgentJournalDispatchRejection, @@ -18,6 +20,21 @@ import { } from '../../../shared/agent-session-wire-refusals' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +/** Start refusals whose situation is itself what the person reads, with its own next step. */ +const TYPED_START_REFUSALS = [ + 'notSignedIn', + 'historyTooLarge', + 'managedAccountEnvOverride', + 'accountSwitchInProgress' +] as const satisfies readonly (AgentSessionFailureKind & + AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] + +function typedStartRefusal( + reason: string | undefined +): (typeof TYPED_START_REFUSALS)[number] | undefined { + return TYPED_START_REFUSALS.find((typed) => typed === reason) +} + /** Marks the error an adapter observed its child's exit with, where it observed it. */ export function withObservedProviderExit(error: TError): TError { return Object.assign(error, { providerExitObserved: true }) @@ -45,11 +62,11 @@ export function providerExitObserved(error: unknown, depth = 0): boolean { * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ export function providerStartupFailureFact(cause?: unknown): AgentSessionFailureFact { - if ( - cause instanceof AgentSessionAcquisitionRefusal && - (cause.reason === 'notSignedIn' || cause.reason === 'historyTooLarge') - ) { - return agentSessionFailureFact(cause.reason) + const typed = typedStartRefusal( + cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined + ) + if (typed) { + return agentSessionFailureFact(typed) } return agentSessionFailureFact( providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', @@ -76,8 +93,9 @@ function refusedStartFailureFact( ): AgentSessionFailureFact { const { refusal, diagnostic } = cause const reason = refusal.details?.reason - if (reason === 'notSignedIn' || reason === 'historyTooLarge') { - return agentSessionFailureFact(reason) + const typed = typedStartRefusal(reason) + if (typed) { + return agentSessionFailureFact(typed) } if (reason === 'providerStartFailed') { return agentSessionFailureFact('providerStartFailed', { detail: diagnostic }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts new file mode 100644 index 00000000000..5f56542fcab --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -0,0 +1,179 @@ +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 { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { mapRuntimeError } from '../../runtime/rpc/errors' +import { + AgentSessionPreSpawnError, + type AgentSessionPreSpawnReason, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +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 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + vi.restoreAllMocks() + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function createParams(): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native' + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +/** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ +async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { + root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const unused = async (): Promise => { + throw new Error('not reached before a spawn') + } + const adapter: StructuredAgentSessionAdapter = { + acquire: async () => { + throw thrown + }, + dispatch: unused, + cancelTurn: unused, + answerPrompt: unused, + setOption: unused + } + const input = { + store, + adapter, + journalRoot: root, + openConversation: openTestAttachConversation(root), + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: createParams(), + now: () => NOW, + onAttached: () => {} + } + const first = await performAttach(input).then( + () => null, + (error: unknown) => error + ) + expect(first).toBeInstanceOf(AgentSessionPreSpawnError) + return { + first: mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first), + replay: await performAttach(input) + } +} + +describe('a create that fails before any process spawns', () => { + it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ + [ + 'the managed account env override', + 'This Claude launch defines explicit Anthropic auth environment variables.', + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'an account switch in progress', + 'A Claude account switch is in progress. Try again after it finishes.', + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + "Orca's own reason", + 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', + undefined, + "Claude couldn't start. Send your message to try again." + ] + ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const { first, replay } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) + ) + + // Thrown as before: the wire code is unchanged, only the words are the replay's. + expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) + expect(replay).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: sentence, + ...(reason ? { details: { reason } } : {}) + } + }) + if (!reason) { + expect(replay).not.toHaveProperty('refusal.details') + } + if (raw !== sentence) { + expect(JSON.stringify([first, replay])).not.toContain(raw) + } + // What failed is kept for the log. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: raw }) + ) + }) + + it('keeps a message that is itself a code, and the wire code it maps to', async () => { + const { first } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error('runtime_unavailable')) + ) + + expect(first).toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } + }) + }) + + it('keeps the situation of a pre-spawn error it wraps', () => { + const typed = new AgentSessionPreSpawnError(new Error('switching'), { + reason: 'accountSwitchInProgress' + }) + + expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') + }) +}) 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 d1ab41ccaef..348d5a3d90c 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 @@ -240,7 +240,7 @@ describe('processless structured session reservation', () => { now: () => NOW, onAttached: () => {} }) - ).rejects.toThrow('workspace no longer exists') + ).rejects.toThrow("Codex couldn't restart. Send your message to try again.") expect(settlement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ exitProof: 'processless', spawnToken: 'spawn-a' }) ) @@ -308,7 +308,9 @@ describe('processless structured session reservation', () => { onAttached: () => {} } - await expect(performAttach(input)).rejects.toThrow('launch not ready') + await expect(performAttach(input)).rejects.toThrow( + "Codex couldn't restart. Send your message to try again." + ) await expect(performAttach(input)).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 45311013fb2..346f3c39cba 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -364,9 +364,28 @@ describe('a structured Claude session over agentSession.*', () => { claudeAuthPolicy = { stripAuthEnv: true } // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. - const refused = await call('agentSession.create', createIntentParams()) - - expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment') + const params = createIntentParams() + const sentence = + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + const refused = await call('agentSession.create', params) + + // The thrown answer keeps its wire code; only its words are the ones its replay reads. + expect(refused).toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: sentence } + }) + // Its replay reads the same sentence, beside the situation it names. + expect(await call('agentSession.create', params)).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'managedAccountEnvOverride' }, + message: sentence + } + } + }) // Refused before spawn: no provider child was ever opened. expect(claude.connections).toHaveLength(0) }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 25e0e7d16da..83faa528714 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -163,6 +163,10 @@ const FAILURE_SENTENCES = { `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, historyTooLarge: () => "This conversation's history is too large to restore here. Start a new chat to continue.", + managedAccountEnvOverride: () => + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', + accountSwitchInProgress: () => + 'A Claude account switch is in progress. Try again after it finishes.', providerExited: (_, __, surface) => surface === 'row' ? 'The provider stopped while this response was in progress. You can continue in this conversation.' diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index e753d98dc23..d7843c626aa 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -19,6 +19,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'startFailed', 'notSignedIn', 'historyTooLarge', + 'managedAccountEnvOverride', + 'accountSwitchInProgress', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 9c7063b63ea..fb206068562 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -43,6 +43,9 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'providerStartFailed', 'notSignedIn', 'historyTooLarge', + /** The launch's own Anthropic sign-in variables would override the managed Claude account. */ + 'managedAccountEnvOverride', + 'accountSwitchInProgress', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 7fe0992ba73..7dca11885c5 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -82,6 +82,8 @@ const KIND_CATEGORY = { startFailed: 'startFailed', notSignedIn: 'startFailed', historyTooLarge: 'startFailed', + managedAccountEnvOverride: 'startFailed', + accountSwitchInProgress: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', From 82e6a2c503d4393b51c4005c5b68bc6c77e48802 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:28:54 -0700 Subject: [PATCH 098/122] fix(native-chat): say how to retry after an agent stopped before it finished starting " stopped before it finished starting." gave no next step outside /clear, unlike every other failed start. It now ends "Send your message to try again.", the same step a start or restart that could not run gives; after /clear it still says "Run /clear again." --- .../structured-agent-session-append-delivery.test.ts | 4 +++- ...ed-agent-session-dead-generation-settlement.test.ts | 2 +- ...d-agent-session-failed-create-owner-verdict.test.ts | 3 ++- .../structured-agent-session-failure-text.test.ts | 4 ++-- ...uctured-agent-session-provider-child-record.test.ts | 2 +- .../structured-agent-session-refusal-details.test.ts | 2 +- ...ed-agent-session-send-restarts-failed-start.test.ts | 10 ++++++---- ...ructured-agent-session-startup-failure-exit.test.ts | 2 +- ...agent-session-view-start-after-failed-start.test.ts | 3 ++- .../claude-structured-failed-start-resume.test.ts | 2 +- .../claude-structured-resumed-start-failure.test.ts | 2 +- .../claude-structured-send-held-for-startup.test.ts | 2 +- ...tructured-send-restart-dies-before-dispatch.test.ts | 2 +- .../claude-structured-session-integration.test.ts | 4 ++-- src/shared/agent-session-failure-words.test.ts | 6 ++++-- src/shared/agent-session-failure-words.ts | 7 +------ 16 files changed, 30 insertions(+), 27 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 8d9dbbca07d..55128a23c9c 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 @@ -166,7 +166,9 @@ describe('an open chat receives every row its journal commits', () => { ) ) // One row, however many of its writers reported the start. - expect(pane.received().statusRows).toEqual(['Codex stopped before it finished starting.']) + expect(pane.received().statusRows).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' + ]) }) it('shows a revision the provider queued with no publish behind it', async () => { 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 093c54cf2d8..b60cb6662d4 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 @@ -320,7 +320,7 @@ describe('dead structured-session generation settlement', () => { expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', - reason: 'The agent stopped before it finished starting.', + reason: 'The agent stopped before it finished starting. Send your message to try again.', rejection: { kind: 'providerStartFailed', detail: { text: 'code 1\nnot signed in', audience: 'log' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 63e65d67e78..317e7ca81a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -26,7 +26,8 @@ const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' // The refusal says what the chat's start failure says; the error text stays in the log. const COULD_NOT_RESTART = "Codex couldn't restart. Send your message to try again." -const PROVIDER_STOPPED = 'Codex stopped before it finished starting.' +const PROVIDER_STOPPED = + 'Codex stopped before it finished starting. Send your message to try again.' let root: string let store: AgentSessionRecordStore diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index d14de6b2e1c..b2dc287c4fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -158,7 +158,7 @@ describe('structuredAgentSessionStartFailure', () => { diagnostic: { text: 'code 1', audience: 'log' } }) ).toEqual({ - reason: 'The agent stopped before it finished starting.', + reason: 'The agent stopped before it finished starting. Send your message to try again.', rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } }) // A start that threw proves nothing about the provider: it may be Orca's, or a failed spawn. @@ -175,7 +175,7 @@ describe('structuredAgentSessionStartFailure', () => { expect( structuredAgentSessionStartFailure({ error: new Error('wrapped', { cause: exit }) }) ).toEqual({ - reason: 'The agent stopped before it finished starting.', + reason: 'The agent stopped before it finished starting. Send your message to try again.', rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } }) }) 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 252869b6b61..466dc32bb95 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 @@ -366,7 +366,7 @@ describe('settling an earlier child before the next one takes its message', () = }) const START_EXIT = 'claude stream-json exited (code 1)' -const START_TEXT = 'Codex stopped before it finished starting.' +const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' const START_FAILURE: AgentSessionFailureFact = { kind: 'providerStartFailed', detail: { text: START_EXIT, audience: 'log' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts index 485f7ca67e5..505bec74c23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -75,7 +75,7 @@ describe('a ledger replay names the details its first answer did', () => { new Error('claude stream-json exited (code 1): claude: not signed in (rig)') ) ), - 'Claude stopped before it finished starting.' + 'Claude stopped before it finished starting. Send your message to try again.' ], [ new AgentSessionAcquisitionExitProvenError(new Error('spawn claude ENOENT')), 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 23f4f4a1111..d1d27b58eac 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 @@ -203,7 +203,7 @@ describe('a send into a published session whose child ended before startup', () // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. expect(submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: 'Codex stopped before it finished starting.', + reason: 'Codex stopped before it finished starting. Send your message to try again.', rejection: STARTUP_FAILURE, recovered: true }) @@ -211,7 +211,7 @@ describe('a send into a published session whose child ended before startup', () host.journalSnapshot(SESSION).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) expect(journalStatuses().slice(rowsBefore)).toEqual([ - 'Codex stopped before it finished starting.' + 'Codex stopped before it finished starting. Send your message to try again.' ]) // The failed restart moved the fence twice: the acquisition, and the exit that released it. expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) @@ -248,12 +248,14 @@ describe('a send while the child of the first start is still proving itself', () expect(submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: 'Codex stopped before it finished starting.', + reason: 'Codex stopped before it finished starting. Send your message to try again.', rejection: STARTUP_FAILURE, recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual(['Codex stopped before it finished starting.']) + expect(journalStatuses()).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' + ]) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 201a875b531..3064676f596 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 @@ -13,7 +13,7 @@ import { const SESSION = 'session-1' const GENERATION = 'generation-1' const REASON = 'claude stream-json exited (code 1): session limit reached' -const STARTUP_TEXT = 'Claude stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' function startedSession(): StructuredAgentSessionUnexpectedExitSession & { journal: { appendLifecycleBatch: ReturnType } 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 c65b65622e9..f446d7d6808 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 @@ -181,7 +181,8 @@ describe('a fresh chat whose Claude start fails', () => { } await settleExits() // The row says the provider stopped; its stderr stays out of the sentence. - const startFailure = 'Claude stopped before it finished starting.' + const startFailure = + 'Claude stopped before it finished starting. Send your message to try again.' // Opening the chat: the create's start, once, and its row. expect(claude.connections).toHaveLength(1) expect(timeline()).toEqual([startFailure]) diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index 67bbed97de3..cd4f44d59f8 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -66,7 +66,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { expect(created).toMatchObject({ ok: false, refusal: { - message: 'Claude stopped before it finished starting.', + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } }) diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 97629b5100c..575f0782c97 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -15,7 +15,7 @@ import { const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in' -const STARTUP_TEXT = 'Claude stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) 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 26f3a84f513..52f33cce6e2 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 @@ -18,7 +18,7 @@ import { const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' -const STARTUP_TEXT = 'Claude stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 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 a9f1d3ab7e1..5dd1775e6e0 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 @@ -19,7 +19,7 @@ import { 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)' -const STARTUP_TEXT = 'Claude stopped before it finished starting.' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => void): Promise { diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 346f3c39cba..a6a34c9834d 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -429,7 +429,7 @@ describe('a structured Claude session over agentSession.*', () => { refusal: { code: 'agent_session_operation_invalid', // The CLI's stderr is log text; the person reads what the chat's start failure says. - message: 'Claude stopped before it finished starting.', + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } } @@ -464,7 +464,7 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'Claude stopped before it finished starting.', + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } } diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index b4d3b0aff32..6aab0dfdacf 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -125,9 +125,11 @@ describe('the words written beside a failure fact', () => { const sentence = (kind: AgentSessionFailureKind, agentName?: string) => agentSessionFailureSentence({ kind }, 'rejection', { agentName }) expect(sentence('providerStartFailed', 'Claude')).toBe( - 'Claude stopped before it finished starting.' + 'Claude stopped before it finished starting. Send your message to try again.' + ) + expect(sentence('providerStartFailed')).toBe( + 'The agent stopped before it finished starting. Send your message to try again.' ) - expect(sentence('providerStartFailed')).toBe('The agent stopped before it finished starting.') expect(sentence('startFailed', 'Codex')).toBe( "Codex couldn't start. Send your message to try again." ) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 83faa528714..073da9b38f5 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -101,11 +101,6 @@ function retryStep({ command }: AgentSessionFailureWordsContext): string { return command === 'clear' ? 'run /clear again' : 'send your message again' } -/** The next step a start failure with none of its own leaves: a command, run again. */ -function commandRetry({ command }: AgentSessionFailureWordsContext): string { - return command === 'clear' ? ' Run /clear again.' : '' -} - /** The next step after a start or restart that failed: the command, or the message, again. */ function startRetry({ command }: AgentSessionFailureWordsContext): string { return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' @@ -157,7 +152,7 @@ const ATTACHMENT_SENTENCES = { const FAILURE_SENTENCES = { providerStartFailed: (context) => - `${context.agentName ?? 'The agent'} stopped before it finished starting.${commandRetry(context)}`, + `${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`, startFailed: couldNot('start'), notSignedIn: (context) => `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, From 248c23c4902e94d0b832a3f9737a66c8ff43eebc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:44:31 -0700 Subject: [PATCH 099/122] fix(native-chat): say how to reach a Claude chat when a WSL Claude account blocks it A Claude chat that restarts while a Claude account is added in WSL and no Windows Claude account is selected was refused before spawn with Orca's own text as its first answer, and the generic start sentence on replay. The refusal is now typed where the account gate throws, and both answers read the same sentence: choose or add a Windows Claude account in Claude Accounts settings, then send the message again. Account settings that cannot be read name no situation and keep the generic sentence. --- .../claude-structured-launch-resolution.test.ts | 17 +++++++++-------- .../claude-structured-launch-resolution.ts | 11 ++++++----- .../structured-agent-session-adapter.ts | 2 +- ...tructured-agent-session-failure-text.test.ts | 4 ++++ .../structured-agent-session-failure-text.ts | 3 ++- ...agent-session-pre-spawn-first-answer.test.ts | 6 ++++++ ...claude-structured-managed-account-support.ts | 9 ++++++++- src/shared/agent-session-failure-words.ts | 2 ++ src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-refusal-details.ts | 2 ++ ...ructured-agent-session-dispatch-rejection.ts | 1 + 11 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index c54d1a5179f..3d7592c8406 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -492,16 +492,17 @@ describe('claude structured launch resolution', () => { gate = WSL_ONLY_NORMALIZED - // Reacquire after the account state changed: refused before anything spawns. - await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf( - AgentSessionPreSpawnError - ) + // Reacquire after the account state changed: refused before anything spawns, naming the + // account shape a person can change. + const refused = resolve({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) }) - it('fails closed when the account state cannot be read', async () => { - await expect( - resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) - ).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + it('fails closed when the account state cannot be read, naming no situation', async () => { + const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: undefined }) }) it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 30ad2212e46..d5d0542cb9d 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -22,6 +22,7 @@ import { } from '../claude-accounts/live-pty-gate' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { + hasWslBoundClaudeAccount, structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -251,12 +252,12 @@ export function createClaudeStructuredLaunchResolver( // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. // Codex has no gate here — it resolves its account on a different path. - if ( - deps.readManagedAccountGate && - !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate()) - ) { + const gate = deps.readManagedAccountGate?.() + if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { + // Unreadable account state names no situation a person can act on, so only the log reads it. throw new AgentSessionPreSpawnError( - 'structured Claude is not offered under the active managed Claude account' + 'structured Claude is not offered under the active managed Claude account', + gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } const head = agentSessionProviderHandleChainHead(record.providerHandleChain) 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 90c311380c0..a0bdc86d2b4 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 @@ -120,7 +120,7 @@ export type AgentSessionAcquisition = { /** A refusal before spawn that a person can act on; the site that refused names it. */ export type AgentSessionPreSpawnReason = Extract< AgentSessionRefusalReason<'agent_session_operation_invalid'>, - 'managedAccountEnvOverride' | 'accountSwitchInProgress' + 'managedAccountEnvOverride' | 'accountSwitchInProgress' | 'managedAccountUnsupported' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index b2dc287c4fd..fcebdda7726 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -61,6 +61,10 @@ describe('structuredAgentSessionStartFailure', () => { [ 'accountSwitchInProgress', 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' ] ] as const)('words a start refused for %s by that situation', (reason, sentence) => { const code = 'agent_session_operation_invalid' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 6d2c033299a..0eba1288d97 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -25,7 +25,8 @@ const TYPED_START_REFUSALS = [ 'notSignedIn', 'historyTooLarge', 'managedAccountEnvOverride', - 'accountSwitchInProgress' + 'accountSwitchInProgress', + 'managedAccountUnsupported' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 5f56542fcab..89772d45469 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -122,6 +122,12 @@ describe('a create that fails before any process spawns', () => { 'accountSwitchInProgress', 'A Claude account switch is in progress. Try again after it finishes.' ], + [ + 'a Claude account added in WSL', + 'structured Claude is not offered under the active managed Claude account', + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], [ "Orca's own reason", 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts index dccf6216bda..3b42c44c443 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -43,12 +43,19 @@ export function structuredClaudeMatchesActiveManagedAccount( // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no // identity, and the WSL-only case, where the prune emptied the host slot and persisted null // while the UI still names the WSL account. The presence of any WSL-bound account decides. - return !accounts.some((candidate) => candidate.managedAuthRuntime === 'wsl') + return !hasWslBoundClaudeAccount(settings) } const active = accounts.find((candidate) => candidate.id === activeHostId) return active ? active.managedAuthRuntime !== 'wsl' : false } +/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ +export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { + return (settings.claudeManagedAccounts ?? []).some( + (candidate) => candidate.managedAuthRuntime === 'wsl' + ) +} + /** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ export function readClaudeManagedAccountGateSettings( getSettings: () => ClaudeManagedAccountGateSettings diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 073da9b38f5..5f90416d130 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -162,6 +162,8 @@ const FAILURE_SENTENCES = { 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', accountSwitchInProgress: () => 'A Claude account switch is in progress. Try again after it finishes.', + managedAccountUnsupported: (context) => + `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then ${retryStep(context)}.`, providerExited: (_, __, surface) => surface === 'row' ? 'The provider stopped while this response was in progress. You can continue in this conversation.' diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index d7843c626aa..55953a7e99c 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -21,6 +21,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'historyTooLarge', 'managedAccountEnvOverride', 'accountSwitchInProgress', + 'managedAccountUnsupported', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index fb206068562..e5a9be90122 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -46,6 +46,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** The launch's own Anthropic sign-in variables would override the managed Claude account. */ 'managedAccountEnvOverride', 'accountSwitchInProgress', + /** A Claude account is added in WSL and no Windows one is selected, which a chat can't run under. */ + 'managedAccountUnsupported', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 7dca11885c5..13b84938174 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -84,6 +84,7 @@ const KIND_CATEGORY = { historyTooLarge: 'startFailed', managedAccountEnvOverride: 'startFailed', accountSwitchInProgress: 'startFailed', + managedAccountUnsupported: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', From d1506de31c1cf8c27e591e10238a14a01e33be90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:23:48 -0700 Subject: [PATCH 100/122] fix(native-chat): say the reason a host names for a refused chat write A refused Stop, answer, setting, goal, command or queued message now reads the refusal's reason as well as its code. A code stands for several situations, so the code alone could only say what did not happen; with the reason, the notice says why and, where the person has a step to take, what it is: "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." The phone uses the same words. The notice table keys on code, then reason, then the kind of write. Every reason of every code has an entry, so a reason the host adds does not compile until it has words; a reason whose honest words are its code's keeps the code's row. A refusal with no reason, or one this build does not know, reads exactly as before, which is what an older host gets. A start that failed reuses the failure row's own sentence rather than a second one. A queued message keeps the reason, the rewind reason and the owner's verdict with its saved failure, and a rejected one keeps the host's typed fact without its provider detail. Nothing that moves with the owner or comes from the provider is saved; entries saved before this load as they were. The saved failure moves to its own module beside the words chosen from it. --- .../mobile-structured-agent-session-rpc.ts | 10 +- ...structured-operation-id-retirement.test.ts | 30 ++ .../agent-session-write-notice-text.ts | 69 +++- ...tured-agent-session-outbox-storage.test.ts | 68 ++++ .../use-structured-agent-session-mutate.ts | 2 +- ...tured-agent-session-write-refusal.test.tsx | 43 +++ .../src/i18n/en-runtime-required.json | 29 +- src/renderer/src/i18n/locales/en.json | 29 +- src/renderer/src/i18n/locales/es.json | 29 +- src/renderer/src/i18n/locales/fr.json | 29 +- src/renderer/src/i18n/locales/ja.json | 29 +- src/renderer/src/i18n/locales/ko.json | 29 +- src/renderer/src/i18n/locales/zh.json | 29 +- ...structured-agent-session-launch-options.ts | 2 +- .../structured-agent-session-launch-prompt.ts | 3 +- src/shared/agent-session-failure-words.ts | 11 +- .../agent-session-refusal-notice.test.ts | 171 +++++++-- src/shared/agent-session-refusal-notice.ts | 338 +++++++++++++----- .../agent-session-write-failure.test.ts | 124 +++++++ src/shared/agent-session-write-failure.ts | 151 ++++++++ src/shared/structured-agent-session-outbox.ts | 53 ++- ...red-agent-session-send-disposition.test.ts | 51 +++ ...ructured-agent-session-send-disposition.ts | 17 +- 23 files changed, 1188 insertions(+), 158 deletions(-) create mode 100644 src/shared/agent-session-write-failure.test.ts create mode 100644 src/shared/agent-session-write-failure.ts diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 712b181cbbc..bca14e293f7 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -10,13 +10,15 @@ import type { import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' import { agentSessionRefusalNotice, - agentSessionRpcErrorFailure, agentSessionWriteFailureNotice, - agentSessionWriteKindForMethod, agentSessionWriteNoticeEnglish, - agentSessionWriteNoticeParts, - type AgentSessionWriteKind + agentSessionWriteNoticeParts } from '../../../src/shared/agent-session-refusal-notice' +import { + agentSessionRpcErrorFailure, + agentSessionWriteKindForMethod, + type AgentSessionWriteKind +} from '../../../src/shared/agent-session-write-failure' import { structuredSessionOperationId } from './structured-session-operation-id' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcClient } from '../transport/rpc-client' diff --git a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts index 887aeb7e208..82ccd1e19dc 100644 --- a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts +++ b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts @@ -161,6 +161,36 @@ describe('what a structured refusal says on the phone', () => { }) }) + it('says what the host named as the reason, in the same words as desktop', async () => { + const result = await requestStructuredAgentSessionMutation({ + client: fakeClient(async () => ({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'This conversation has been cleared. Use the current conversation.', + details: { reason: 'conversationCleared' } + } + }, + _meta: { runtimeId: 'runtime-1' } + })), + method: 'agentSession.send', + fingerprintMethod: 'agentSession.send', + sessionId: 'session-1', + expectedRuntimeFence: 1, + fields: { body: 'hello' }, + clientOperationId: `1900000000000-${'e'.repeat(32)}` + }) + + expect(result).toEqual({ + status: 'refused', + code: 'agent_session_operation_invalid', + message: + 'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.' + }) + }) + const stop = { method: 'agentSession.cancel', fingerprintMethod: 'agentSession.cancel', diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index b8d4b919db8..02528cc862c 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -5,11 +5,13 @@ import { translate } from '@/i18n/i18n' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, agentSessionWriteNoticeParts, - type AgentSessionWriteFailure, - type AgentSessionWriteKind, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from '../../../../shared/agent-session-refusal-notice' +import type { + AgentSessionWriteFailure, + AgentSessionWriteKind +} from '../../../../shared/agent-session-write-failure' const SENTENCES: Record string> = { notDoneSend: () => translate('components.native-chat.writeNotice.notDoneSend', COPY.notDoneSend), @@ -37,7 +39,68 @@ const SENTENCES: Record string> = { historyUnreadable: () => translate('components.native-chat.writeNotice.historyUnreadable', COPY.historyUnreadable), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), - unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable) + unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), + recordFailed: () => + translate('components.native-chat.writeNotice.recordFailed', COPY.recordFailed), + conversationCleared: () => + translate('components.native-chat.writeNotice.conversationCleared', COPY.conversationCleared), + openCurrentConversation: () => + translate( + 'components.native-chat.writeNotice.openCurrentConversation', + COPY.openCurrentConversation + ), + commandRunning: () => + translate('components.native-chat.writeNotice.commandRunning', COPY.commandRunning), + waitForCommand: () => + translate('components.native-chat.writeNotice.waitForCommand', COPY.waitForCommand), + agentStarting: () => + translate('components.native-chat.writeNotice.agentStarting', COPY.agentStarting), + waitForStart: () => + translate('components.native-chat.writeNotice.waitForStart', COPY.waitForStart), + turnActive: () => translate('components.native-chat.writeNotice.turnActive', COPY.turnActive), + waitForTurn: () => translate('components.native-chat.writeNotice.waitForTurn', COPY.waitForTurn), + promptPending: () => + translate('components.native-chat.writeNotice.promptPending', COPY.promptPending), + answerFirst: () => translate('components.native-chat.writeNotice.answerFirst', COPY.answerFirst), + backgroundTasksRunning: () => + translate( + 'components.native-chat.writeNotice.backgroundTasksRunning', + COPY.backgroundTasksRunning + ), + waitForBackgroundTasks: () => + translate( + 'components.native-chat.writeNotice.waitForBackgroundTasks', + COPY.waitForBackgroundTasks + ), + messagesUnsettled: () => + translate('components.native-chat.writeNotice.messagesUnsettled', COPY.messagesUnsettled), + settleEarlierMessage: () => + translate('components.native-chat.writeNotice.settleEarlierMessage', COPY.settleEarlierMessage), + optionRejected: () => + translate('components.native-chat.writeNotice.optionRejected', COPY.optionRejected), + goalsUnsupported: () => + translate('components.native-chat.writeNotice.goalsUnsupported', COPY.goalsUnsupported), + agentRefused: () => + translate('components.native-chat.writeNotice.agentRefused', COPY.agentRefused), + ownerUnproven: () => + translate('components.native-chat.writeNotice.ownerUnproven', COPY.ownerUnproven), + reopenChat: () => translate('components.native-chat.writeNotice.reopenChat', COPY.reopenChat), + terminalAgentHoldsChat: () => + translate( + 'components.native-chat.writeNotice.terminalAgentHoldsChat', + COPY.terminalAgentHoldsChat + ), + quitTerminalAgent: () => + translate('components.native-chat.writeNotice.quitTerminalAgent', COPY.quitTerminalAgent), + hostReconciling: () => + translate('components.native-chat.writeNotice.hostReconciling', COPY.hostReconciling), + waitMoment: () => translate('components.native-chat.writeNotice.waitMoment', COPY.waitMoment), + recordUnreadable: () => + translate('components.native-chat.writeNotice.recordUnreadable', COPY.recordUnreadable), + chatNotFound: () => + translate('components.native-chat.writeNotice.chatNotFound', COPY.chatNotFound), + startNewChat: () => + translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat) } export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNoticePart[]): string { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts index da60412183f..9b8b3c537a6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts @@ -115,7 +115,18 @@ describe("a saved message's last failure", () => { it.each([ { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'chatStarting' } }, + { + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ownerVerdict: 'unverifiable' } + }, { kind: 'rejected', reason: 'Claude messages support at most 20 images' }, + { + kind: 'rejected', + reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } } + }, { kind: 'rejected', reason: null }, { kind: 'failed' } ])('reads back $kind as it was saved', (lastFailure) => { @@ -137,4 +148,61 @@ describe("a saved message's last failure", () => { expect(read.map((saved) => saved.clientMessageId)).toEqual(['client-1', 'client-2']) expect(read.every((saved) => saved.lastFailure === undefined)).toBe(true) }) + + // Written by a build that kept only the code or the reason; it reads as that build meant it. + it('reads an entry saved before failures carried details as it was', () => { + localStorage.setItem( + 'orca:desktopStructuredAgentSessionOutbox:v1:session-a', + JSON.stringify([ + { + ...entry('session-a', 'client-1'), + lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' } + }, + { ...entry('session-a', 'client-2'), lastFailure: { kind: 'rejected', reason: 'Nope.' } } + ]) + ) + + expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([ + { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { kind: 'rejected', reason: 'Nope.' } + ]) + }) + + it('keeps only what a newer build saved that this one can place', () => { + localStorage.setItem( + 'orca:desktopStructuredAgentSessionOutbox:v1:session-a', + JSON.stringify([ + { + ...entry('session-a', 'client-1'), + lastFailure: { + kind: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fromTheFuture', currentFence: 4 } + } + }, + { + ...entry('session-a', 'client-2'), + lastFailure: { + kind: 'rejected', + reason: 'Claude stopped before it finished starting.', + rejection: { kind: 'providerStartFailed', detail: { text: 'exit 1', audience: 'log' } } + } + }, + { + ...entry('session-a', 'client-3'), + lastFailure: { kind: 'rejected', reason: 'Nope.', rejection: { kind: 'fromTheFuture' } } + } + ]) + ) + + expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([ + { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { + kind: 'rejected', + reason: 'Claude stopped before it finished starting.', + rejection: { kind: 'providerStartFailed' } + }, + { kind: 'rejected', reason: 'Nope.' } + ]) + }) }) 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 5d20ce6a110..328d76d0534 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 @@ -14,7 +14,7 @@ import { agentSessionRefusalFailure, agentSessionRpcErrorFailure, agentSessionWriteKindForMethod as writeKind -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-failure' import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index ac09dde1da8..0336e81401a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -147,4 +147,47 @@ describe('a chat write the host refused', () => { expect(mocks.toastError).not.toHaveBeenCalled() expect(result.current.error).toBeNull() }) + + it('says the reason the host named, for a Stop and for a command', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: false, + refusal: + method === 'agentSession.cancel' + ? { + code: 'agent_session_conflict', + message: 'The chat is still starting.', + details: { reason: 'chatStarting' } + } + : { + code: 'agent_session_operation_invalid', + message: 'Wait for the current turn to finish before using this command.', + details: { reason: 'turnActive' } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.cancel('turn-1')).resolves.toBeNull() + await expect(result.current.runConversationCommand('compact')).resolves.toEqual({ + accepted: false, + error: + "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." + }) + }) + + expect(mocks.toastError).toHaveBeenCalledWith( + "The agent is still starting. The agent wasn't stopped. Wait for the agent to finish starting." + ) + }) }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 86d143fa2a6..e65c931a7de 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2710,8 +2710,18 @@ "usedOneSummary": "Used 1 tool" }, "writeNotice": { + "agentRefused": "The agent turned this down.", + "agentStarting": "The agent is still starting.", + "answerFirst": "Answer the question or approval first.", + "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", + "chatNotFound": "The Orca running this chat has no record of it.", + "commandRunning": "A /compact or /clear is still running.", + "conversationCleared": "This conversation has been cleared.", + "goalsUnsupported": "This agent doesn't support goals.", "historyUnreadable": "Orca couldn't read this chat's saved history.", + "hostReconciling": "Orca is still checking on this chat after restarting.", + "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", "notDoneAnswer": "Your answer was not sent.", "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", @@ -2720,12 +2730,29 @@ "notDoneStop": "The agent wasn't stopped.", "notDoneStopTask": "The background task wasn't stopped.", "notDoneStopTasks": "The background tasks weren't stopped.", + "openCurrentConversation": "Open the current conversation to continue.", + "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", + "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", + "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", + "quitTerminalAgent": "Quit that agent to continue the chat here.", + "recordFailed": "Orca couldn't record it in this chat's history.", + "recordUnreadable": "Orca couldn't read this chat's saved state.", + "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", + "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "startNewChat": "Start a new chat to continue.", + "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", "tryAgainComposerSend": "Send it again.", + "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", - "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again." + "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", + "waitForBackgroundTasks": "Wait for the background tasks to finish.", + "waitForCommand": "Wait for the /compact or /clear to finish.", + "waitForStart": "Wait for the agent to finish starting.", + "waitForTurn": "Wait for the agent to finish responding, or stop it.", + "waitMoment": "Wait a moment." } }, "onboarding": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 14a780e7612..f532e33df6d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17481,7 +17481,34 @@ "questionChanged": "This question was already answered or has changed.", "historyUnreadable": "Orca couldn't read this chat's saved history.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", - "unreachable": "Orca couldn't reach the agent." + "unreachable": "Orca couldn't reach the agent.", + "recordFailed": "Orca couldn't record it in this chat's history.", + "conversationCleared": "This conversation has been cleared.", + "openCurrentConversation": "Open the current conversation to continue.", + "commandRunning": "A /compact or /clear is still running.", + "waitForCommand": "Wait for the /compact or /clear to finish.", + "agentStarting": "The agent is still starting.", + "waitForStart": "Wait for the agent to finish starting.", + "turnActive": "The agent is still responding.", + "waitForTurn": "Wait for the agent to finish responding, or stop it.", + "promptPending": "The agent is waiting for an answer to a question or approval.", + "answerFirst": "Answer the question or approval first.", + "backgroundTasksRunning": "Background tasks are still running.", + "waitForBackgroundTasks": "Wait for the background tasks to finish.", + "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", + "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "optionRejected": "The agent didn't accept this setting.", + "goalsUnsupported": "This agent doesn't support goals.", + "agentRefused": "The agent turned this down.", + "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", + "reopenChat": "Reopen the chat to check again.", + "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", + "quitTerminalAgent": "Quit that agent to continue the chat here.", + "hostReconciling": "Orca is still checking on this chat after restarting.", + "waitMoment": "Wait a moment.", + "recordUnreadable": "Orca couldn't read this chat's saved state.", + "chatNotFound": "The Orca running this chat has no record of it.", + "startNewChat": "Start a new chat to continue." }, "taskList": { "title": "Tasks", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a5c6510ac3e..8f03d1042e9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14941,7 +14941,34 @@ "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", - "unreachable": "Orca no pudo comunicarse con el agente." + "unreachable": "Orca no pudo comunicarse con el agente.", + "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", + "conversationCleared": "Esta conversación se borró.", + "openCurrentConversation": "Abra la conversación actual para continuar.", + "commandRunning": "Todavía se está ejecutando un /compact o /clear.", + "waitForCommand": "Espere a que termine el /compact o /clear.", + "agentStarting": "El agente todavía se está iniciando.", + "waitForStart": "Espere a que el agente termine de iniciarse.", + "turnActive": "El agente todavía está respondiendo.", + "waitForTurn": "Espere a que el agente termine de responder o deténgalo.", + "promptPending": "El agente está esperando una respuesta a una pregunta o aprobación.", + "answerFirst": "Responda primero a la pregunta o aprobación.", + "backgroundTasksRunning": "Todavía hay tareas en segundo plano en ejecución.", + "waitForBackgroundTasks": "Espere a que terminen las tareas en segundo plano.", + "messagesUnsettled": "Un mensaje que envió antes todavía no se ha confirmado.", + "settleEarlierMessage": "Espere a que se entregue su mensaje anterior o vuelva a intentarlo.", + "optionRejected": "El agente no aceptó este ajuste.", + "goalsUnsupported": "Este agente no admite objetivos.", + "agentRefused": "El agente lo rechazó.", + "ownerUnproven": "Orca no ha confirmado que el agente anterior de este chat se haya detenido.", + "reopenChat": "Vuelva a abrir el chat para comprobarlo de nuevo.", + "terminalAgentHoldsChat": "Este chat sigue abierto en un agente de terminal.", + "quitTerminalAgent": "Cierre ese agente para continuar el chat aquí.", + "hostReconciling": "Orca todavía está comprobando este chat después de reiniciarse.", + "waitMoment": "Espere un momento.", + "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", + "chatNotFound": "La versión de Orca que ejecuta este chat no tiene ningún registro de él.", + "startNewChat": "Inicie un chat nuevo para continuar." }, "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6d5fe91487b..742a259f98f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17424,7 +17424,34 @@ "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", - "unreachable": "Orca n'a pas pu joindre l'agent." + "unreachable": "Orca n'a pas pu joindre l'agent.", + "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", + "conversationCleared": "Cette conversation a été effacée.", + "openCurrentConversation": "Ouvrez la conversation actuelle pour continuer.", + "commandRunning": "Un /compact ou /clear est encore en cours.", + "waitForCommand": "Attendez la fin du /compact ou /clear.", + "agentStarting": "L'agent est encore en cours de démarrage.", + "waitForStart": "Attendez que l'agent ait fini de démarrer.", + "turnActive": "L'agent est encore en train de répondre.", + "waitForTurn": "Attendez que l'agent ait fini de répondre, ou arrêtez-le.", + "promptPending": "L'agent attend une réponse à une question ou à une demande d'approbation.", + "answerFirst": "Répondez d'abord à la question ou à la demande d'approbation.", + "backgroundTasksRunning": "Des tâches en arrière-plan sont encore en cours.", + "waitForBackgroundTasks": "Attendez la fin des tâches en arrière-plan.", + "messagesUnsettled": "Un message que vous avez envoyé plus tôt n'a pas encore été confirmé.", + "settleEarlierMessage": "Attendez que votre message précédent soit transmis, ou renvoyez-le.", + "optionRejected": "L'agent n'a pas accepté ce réglage.", + "goalsUnsupported": "Cet agent ne prend pas en charge les objectifs.", + "agentRefused": "L'agent l'a refusé.", + "ownerUnproven": "Orca n'a pas confirmé l'arrêt de l'agent précédent de ce chat.", + "reopenChat": "Rouvrez le chat pour vérifier à nouveau.", + "terminalAgentHoldsChat": "Ce chat est encore ouvert dans un agent de terminal.", + "quitTerminalAgent": "Quittez cet agent pour continuer le chat ici.", + "hostReconciling": "Orca vérifie encore ce chat après son redémarrage.", + "waitMoment": "Patientez un instant.", + "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", + "chatNotFound": "L'instance d'Orca qui exécute ce chat n'en a aucune trace.", + "startNewChat": "Démarrez un nouveau chat pour continuer." }, "composer": { "imageUnsupported": "Le collage d'image n'est pas pris en charge pour cet agent.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b946675007f..d48a0033de8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17360,7 +17360,34 @@ "questionChanged": "この質問はすでに回答済みか、変更されています。", "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", - "unreachable": "Orca は Agent に接続できませんでした。" + "unreachable": "Orca は Agent に接続できませんでした。", + "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", + "conversationCleared": "この会話はクリアされました。", + "openCurrentConversation": "続けるには、現在の会話を開いてください。", + "commandRunning": "/compact または /clear がまだ実行中です。", + "waitForCommand": "/compact または /clear が終わるまでお待ちください。", + "agentStarting": "Agent はまだ起動中です。", + "waitForStart": "Agent の起動が終わるまでお待ちください。", + "turnActive": "Agent はまだ応答中です。", + "waitForTurn": "Agent の応答が終わるまで待つか、Agent を停止してください。", + "promptPending": "Agent は質問または承認への回答を待っています。", + "answerFirst": "先に質問または承認に回答してください。", + "backgroundTasksRunning": "バックグラウンドタスクがまだ実行中です。", + "waitForBackgroundTasks": "バックグラウンドタスクが終わるまでお待ちください。", + "messagesUnsettled": "以前に送信したメッセージがまだ確認されていません。", + "settleEarlierMessage": "以前のメッセージが届くまで待つか、再試行してください。", + "optionRejected": "Agent はこの設定を受け入れませんでした。", + "goalsUnsupported": "この Agent は目標に対応していません。", + "agentRefused": "Agent がこれを拒否しました。", + "ownerUnproven": "Orca は、このチャットの以前の Agent が停止したことをまだ確認できていません。", + "reopenChat": "もう一度確認するには、チャットを開き直してください。", + "terminalAgentHoldsChat": "このチャットはまだターミナルの Agent で開かれています。", + "quitTerminalAgent": "ここでチャットを続けるには、その Agent を終了してください。", + "hostReconciling": "Orca は再起動後、このチャットをまだ確認しています。", + "waitMoment": "しばらくお待ちください。", + "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", + "chatNotFound": "このチャットを実行している Orca には、このチャットの記録がありません。", + "startNewChat": "続けるには、新しいチャットを開始してください。" }, "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 29b5959c24a..b59bf5aa9f5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17360,7 +17360,34 @@ "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", - "unreachable": "Orca가 에이전트에 연결하지 못했습니다." + "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", + "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", + "conversationCleared": "이 대화는 지워졌습니다.", + "openCurrentConversation": "계속하려면 현재 대화를 여세요.", + "commandRunning": "/compact 또는 /clear가 아직 실행 중입니다.", + "waitForCommand": "/compact 또는 /clear가 끝날 때까지 기다리세요.", + "agentStarting": "에이전트가 아직 시작 중입니다.", + "waitForStart": "에이전트가 시작을 마칠 때까지 기다리세요.", + "turnActive": "에이전트가 아직 응답 중입니다.", + "waitForTurn": "에이전트가 응답을 마칠 때까지 기다리거나 에이전트를 중지하세요.", + "promptPending": "에이전트가 질문 또는 승인에 대한 응답을 기다리고 있습니다.", + "answerFirst": "먼저 질문 또는 승인에 응답하세요.", + "backgroundTasksRunning": "백그라운드 작업이 아직 실행 중입니다.", + "waitForBackgroundTasks": "백그라운드 작업이 끝날 때까지 기다리세요.", + "messagesUnsettled": "앞서 보낸 메시지가 아직 확인되지 않았습니다.", + "settleEarlierMessage": "앞서 보낸 메시지가 전달될 때까지 기다리거나 다시 시도하세요.", + "optionRejected": "에이전트가 이 설정을 받아들이지 않았습니다.", + "goalsUnsupported": "이 에이전트는 목표를 지원하지 않습니다.", + "agentRefused": "에이전트가 이를 거부했습니다.", + "ownerUnproven": "Orca가 이 채팅의 이전 에이전트가 중지되었는지 아직 확인하지 못했습니다.", + "reopenChat": "다시 확인하려면 채팅을 다시 여세요.", + "terminalAgentHoldsChat": "이 채팅은 아직 터미널 에이전트에서 열려 있습니다.", + "quitTerminalAgent": "여기에서 채팅을 계속하려면 해당 에이전트를 종료하세요.", + "hostReconciling": "Orca가 다시 시작된 후 이 채팅을 아직 확인하고 있습니다.", + "waitMoment": "잠시 기다리세요.", + "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", + "chatNotFound": "이 채팅을 실행하는 Orca에 이 채팅의 기록이 없습니다.", + "startNewChat": "계속하려면 새 채팅을 시작하세요." }, "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 104106ac43f..84da5230005 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17325,7 +17325,34 @@ "questionChanged": "此问题已被回答或已更改。", "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", - "unreachable": "Orca 无法连接到智能体。" + "unreachable": "Orca 无法连接到智能体。", + "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", + "conversationCleared": "此对话已被清除。", + "openCurrentConversation": "请打开当前对话以继续。", + "commandRunning": "/compact 或 /clear 仍在运行。", + "waitForCommand": "请等待 /compact 或 /clear 完成。", + "agentStarting": "智能体仍在启动。", + "waitForStart": "请等待智能体完成启动。", + "turnActive": "智能体仍在回复。", + "waitForTurn": "请等待智能体完成回复,或将其停止。", + "promptPending": "智能体正在等待您回答问题或审批。", + "answerFirst": "请先回答问题或审批。", + "backgroundTasksRunning": "后台任务仍在运行。", + "waitForBackgroundTasks": "请等待后台任务完成。", + "messagesUnsettled": "您之前发送的一条消息尚未确认。", + "settleEarlierMessage": "请等待之前的消息送达,或重试该消息。", + "optionRejected": "智能体未接受此设置。", + "goalsUnsupported": "此智能体不支持目标。", + "agentRefused": "智能体拒绝了此请求。", + "ownerUnproven": "Orca 尚未确认此聊天之前的智能体已停止。", + "reopenChat": "请重新打开聊天以再次检查。", + "terminalAgentHoldsChat": "此聊天仍在终端智能体中打开。", + "quitTerminalAgent": "请退出该智能体,以便在此处继续聊天。", + "hostReconciling": "Orca 重启后仍在检查此聊天。", + "waitMoment": "请稍候。", + "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", + "chatNotFound": "运行此聊天的 Orca 没有此聊天的记录。", + "startNewChat": "请开始新的聊天以继续。" }, "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 36754757504..091e7543f88 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -25,7 +25,7 @@ import { agentSessionRefusalFailure, agentSessionRpcErrorFailure, type AgentSessionWriteFailure -} from '../../../shared/agent-session-refusal-notice' +} from '../../../shared/agent-session-write-failure' /** The options a launch starts with, replaced whole so readers can compare by identity. */ export type StructuredLaunchSelection = { diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 20b95a89b69..84c8fa6e191 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -8,6 +8,7 @@ import { structuredAgentSessionSendRequest, type StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' import { mutateStructuredAgentSessionLaunchPrompt, @@ -111,7 +112,7 @@ async function dispatchStructuredLaunchPrompt( mutateEntry(entry, (current) => requeueStructuredAgentSessionSendRefusal( current, - result.refusal.code, + agentSessionRefusalFailure(result.refusal), () => createStructuredAgentSessionOperationId(createBrowserUuid), entry.lastAttemptAt !== null ) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 5f90416d130..74c2828eb78 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -75,6 +75,11 @@ export const START_REFUSAL_RESUMABLE: Record `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, historyTooLarge: () => - "This conversation's history is too large to restore here. Start a new chat to continue.", + `This conversation's history is too large to restore here. ${START_NEW_CHAT}`, managedAccountEnvOverride: () => 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', accountSwitchInProgress: () => diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 213c4d36232..8bd846fcf42 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -4,17 +4,24 @@ import { type AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { + AGENT_SESSION_WRITE_NOTICE_COPY, agentSessionRefusalNotice, - agentSessionRpcErrorFailure, + agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, - agentSessionWriteKindForMethod, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts, - parseAgentSessionWriteFailure, - type AgentSessionWriteFailure, - type AgentSessionWriteKind, type AgentSessionWriteNoticeSentence } from './agent-session-refusal-notice' +import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details' +import { agentSessionFailureSentence } from './agent-session-failure-words' +import { + agentSessionRefusalFailure, + agentSessionRpcErrorFailure, + agentSessionWriteKindForMethod, + type AgentSessionWriteFailure, + type AgentSessionWriteKind, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' import { DISPATCH_REJECTED_QUEUE_FULL, DISPATCH_REJECTED_WRITE_FAILED @@ -294,30 +301,144 @@ describe('agentSessionRefusalNotice', () => { }) }) -describe('parseAgentSessionWriteFailure', () => { - it('reads back what was saved, and nothing but the code', () => { - expect( - parseAgentSessionWriteFailure( - JSON.parse(JSON.stringify({ kind: 'refused', code: 'agent_session_conflict' })) +// Every (code x reason) a host can name, as a write keeps it. +const REASONED: AgentSessionWriteRefusal[] = AGENT_SESSION_WIRE_REFUSAL_CODES.flatMap((code) => + AGENT_SESSION_REFUSAL_REASONS[code].map((reason) => + agentSessionRefusalFailure({ code, details: { reason } }) + ) +) + +function reasonOf(failure: AgentSessionWriteRefusal): string { + return `${failure.code}/${failure.details?.reason}` +} + +describe('the notice for every reason a host names', () => { + const cells = REASONED.flatMap((failure) => + WRITES.map((write) => ({ + failure, + write, + parts: agentSessionWriteNoticeParts(failure, write), + words: agentSessionRefusalReasonWords(failure), + cell: `${reasonOf(failure)} x ${write}` + })) + ) + + it('has words for every reason', () => { + for (const { words, cell } of cells) { + expect(words, cell).toBeDefined() + } + }) + + it('keeps the code words where the table says the reason means nothing more', () => { + for (const { failure, write, parts, words, cell } of cells) { + if (words && 'words' in words) { + expect(parts, cell).toEqual( + agentSessionWriteNoticeParts({ ...failure, details: undefined }, write) + ) + } + } + }) + + it('names a step exactly where the person has one to take', () => { + for (const { words, cell } of cells) { + if (words && 'cause' in words) { + expect(words.step !== undefined, cell).toBe( + words.action === 'wait' || words.action === 'actFirst' || words.action === 'goElsewhere' + ) + } + } + }) + + it('says the write did not happen, once, and never shows the host message', () => { + for (const { failure, write, parts, cell } of cells) { + const english = agentSessionWriteNoticeEnglish(parts) + expect(english.length, cell).toBeGreaterThan(0) + expect(agentSessionRefusalNotice({ ...failure, message: HOST_TEXT }, write), cell).toBe( + english ) - ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) - expect(parseAgentSessionWriteFailure({ kind: 'failed' })).toEqual({ kind: 'failed' }) + if (failure.code === 'agent_session_operation_unknown') { + expect(parts, cell).toEqual(['outcomeUnknown']) + continue + } + const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) + const answeredAway = write === 'answer' && parts.includes('questionChanged') + const unsupported = failure.code === 'structured_agent_session_unsupported' + expect(notDone, cell).toEqual(answeredAway || unsupported ? [] : [NOT_DONE[write]]) + } + }) + + it("uses the failure's own sentence for a start that failed, and only for a message", () => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + expect(agentSessionWriteNoticeParts(failure, 'send')).toEqual([ + 'notDoneSend', + { text: agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection') } + ]) + // Its next step is to send the message again, which a Stop cannot do. + expect(agentSessionWriteNoticeParts(failure, 'stop')).toEqual(['notDoneStop']) + }) + + it.each([ + [ + 'agent_session_operation_invalid', + 'conversationCleared', + 'send', + 'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.' + ], + [ + 'agent_session_operation_invalid', + 'turnActive', + 'command', + "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." + ], + [ + 'agent_session_conflict', + 'claimConflicted', + 'composer-send', + 'This chat is still open in a terminal agent. Your message was not sent. Quit that agent to continue the chat here.' + ], + [ + 'agent_session_conflict', + 'chatStarting', + 'stop', + "The agent is still starting. The agent wasn't stopped. Wait for the agent to finish starting." + ], + [ + 'agent_session_operation_invalid', + 'promptGone', + 'answer', + 'This question was already answered or has changed.' + ], + [ + 'agent_session_checkpoint_stale', + 'fenceStale', + 'composer-send', + 'Your message was not sent. Send it again.' + ] + ] as const)('%s / %s on %s', (code, reason, write, expected) => { expect( - parseAgentSessionWriteFailure({ - kind: 'refused', - code: 'agent_session_conflict', - message: HOST_TEXT - }) - ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + agentSessionRefusalNotice({ code, message: HOST_TEXT, details: { reason } }, write) + ).toBe(expected) }) +}) +describe('a refusal from a host that names no reason this build knows', () => { it.each([ - null, - 'The agent was restarting.', - { kind: 'refused' }, - { kind: 'refused', code: 'agent_session_from_the_future' }, - { kind: 'something-else' } - ])('drops %j instead of guessing', (value) => { - expect(parseAgentSessionWriteFailure(value)).toBeUndefined() + ['an older host', undefined], + ['a reason a newer host added', { reason: 'fromTheFuture' }], + ['facts with no reason', { ownerVerdict: 'unverifiable' }] + ])("reads as the code's row: %s", (_label, details) => { + const refusal = JSON.parse( + JSON.stringify({ code: 'agent_session_operation_invalid', message: HOST_TEXT, details }) + ) + expect(agentSessionRefusalNotice(refusal, 'command')).toBe("The command didn't run.") }) }) + +it('keeps a sentence the failure rows share word for word', () => { + expect(agentSessionFailureSentence({ kind: 'historyTooLarge' }, 'row')).toContain( + AGENT_SESSION_WRITE_NOTICE_COPY.startNewChat + ) +}) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 4b3db2be0a6..45d65f9814f 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -1,100 +1,32 @@ // The one way a chat surface puts a write that did not happen into words. // // A refusal's `message` is never shown. Every code has at least one host emitter whose message is -// written for a log or carries a marker (the census is pinned in the test), so the code and the -// write pick the copy. A cause is named only where every emitter of the code means it. No notice -// says how to try again: the control that sent the write does that, except on the phone, whose -// message goes back to the composer. Surfaces keep the fact and choose the words when they show -// it, so nothing saved carries copy. +// written for a log or carries a marker (the census is pinned in the test), so the refusal's +// reason, when the host names one, and otherwise its code, pick the copy with the write. A code's +// own row names a cause only where every emitter of the code means it; it is also the words for a +// host too old to send a reason. A notice says how to get past a refusal only where the person has +// a step to take; retrying is the control that sent the write, except on the phone, whose message +// goes back to the composer. Surfaces keep the fact and choose the words when they show it, so +// nothing saved carries copy. +import type { AgentSessionFailureKind } from './agent-session-failure' import { - isAgentSessionWireRefusalCode, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + agentSessionFailureSentence, + QUIT_TERMINAL_AGENT, + START_NEW_CHAT, + TERMINAL_AGENT_HOLDS_CHAT +} from './agent-session-failure-words' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import type { + AgentSessionWireRefusal, + AgentSessionWireRefusalCode } from './agent-session-wire-refusals' - -/** What the person was doing, which decides what the notice says did not happen. `send` keeps - * the message behind a Retry control; `composer-send` puts it back in the composer, as the phone - * does. */ -export type AgentSessionWriteKind = - | 'send' - | 'composer-send' - | 'stop' - | 'stop-task' - | 'stop-tasks' - | 'answer' - | 'option' - | 'command' - | 'goal' - -/** The kind of write an `agentSession.*` call stands for. */ -export function agentSessionWriteKindForMethod( - fingerprintMethod: string, - fields: Record -): AgentSessionWriteKind { - if (fingerprintMethod === 'agentSession.send') { - return 'send' - } - if (fingerprintMethod === 'agentSession.cancel') { - // A background-task stop never asked the agent to stop. - if (fields.scope !== 'background-tasks') { - return 'stop' - } - return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks' - } - if (fingerprintMethod.startsWith('agentSession.respondTo')) { - return 'answer' - } - if (fingerprintMethod === 'agentSession.setOption') { - return 'option' - } - if (fingerprintMethod === 'agentSession.threadGoal') { - return 'goal' - } - return 'command' -} - -/** Why a write did not happen, or that nothing proves it did not. */ -export type AgentSessionWriteFailure = - | { kind: 'refused'; code: AgentSessionWireRefusalCode } - /** The request failed without a refusal before the host ran it, so nothing is known about why. */ - | { kind: 'failed' } - /** The request failed where the host may already have run it (a timeout, a lost connection, an - * error inside the method). */ - | { kind: 'unconfirmed' } - -export function agentSessionRefusalFailure( - refusal: Pick -): AgentSessionWriteFailure { - return { kind: 'refused', code: refusal.code } -} - -/** A request that threw, from the RPC error code the host answered with (undefined when none came - * back). Only a host that turned it away before running the method proves the write did not - * happen. */ -export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure { - if (code === 'method_not_found' || code === 'method_not_supported') { - return { kind: 'refused', code: 'structured_agent_session_unsupported' } - } - return code === 'invalid_argument' || code === 'unauthorized' - ? { kind: 'refused', code: 'agent_session_operation_invalid' } - : { kind: 'unconfirmed' } -} - -/** A saved failure, or undefined when it is not one this build wrote. */ -export function parseAgentSessionWriteFailure( - value: unknown -): AgentSessionWriteFailure | undefined { - if (typeof value !== 'object' || value === null || !('kind' in value)) { - return undefined - } - if (value.kind === 'failed') { - return { kind: 'failed' } - } - return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code) - ? { kind: 'refused', code: value.code } - : undefined -} +import { + agentSessionRefusalFailure, + type AgentSessionWriteFailure, + type AgentSessionWriteKind, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' /** Every sentence a notice is made of. Desktop translates each whole sentence with this as its * fallback; mobile shows it as is. */ @@ -114,7 +46,34 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { questionChanged: 'This question was already answered or has changed.', historyUnreadable: "Orca couldn't read this chat's saved history.", unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", - unreachable: "Orca couldn't reach the agent." + unreachable: "Orca couldn't reach the agent.", + recordFailed: "Orca couldn't record it in this chat's history.", + conversationCleared: 'This conversation has been cleared.', + openCurrentConversation: 'Open the current conversation to continue.', + commandRunning: 'A /compact or /clear is still running.', + waitForCommand: 'Wait for the /compact or /clear to finish.', + agentStarting: 'The agent is still starting.', + waitForStart: 'Wait for the agent to finish starting.', + turnActive: 'The agent is still responding.', + waitForTurn: 'Wait for the agent to finish responding, or stop it.', + promptPending: 'The agent is waiting for an answer to a question or approval.', + answerFirst: 'Answer the question or approval first.', + backgroundTasksRunning: 'Background tasks are still running.', + waitForBackgroundTasks: 'Wait for the background tasks to finish.', + messagesUnsettled: "A message you sent earlier hasn't been confirmed yet.", + settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', + optionRejected: "The agent didn't accept this setting.", + goalsUnsupported: "This agent doesn't support goals.", + agentRefused: 'The agent turned this down.', + ownerUnproven: "Orca hasn't confirmed that this chat's previous agent stopped.", + reopenChat: 'Reopen the chat to check again.', + terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, + quitTerminalAgent: QUIT_TERMINAL_AGENT, + hostReconciling: 'Orca is still checking on this chat after restarting.', + waitMoment: 'Wait a moment.', + recordUnreadable: "Orca couldn't read this chat's saved state.", + chatNotFound: 'The Orca running this chat has no record of it.', + startNewChat: START_NEW_CHAT } as const export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY @@ -142,6 +101,193 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // A /clear or /compact whose outcome the host never settled; only the host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry') + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { journalUnreadable: codeWords('hostFinding') }, + // Thrown, so a client meets these only as an RPC error; the code's words stand. + structured_agent_session_unsupported: { + clientCapabilityMissing: codeWords('updateOrca'), + hostDisabled: codeWords('hostFinding'), + hostUnsupported: codeWords('updateOrca') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; the code's row when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} + +function questionChangedParts(write: AgentSessionWriteKind): AgentSessionWriteNoticeSentence[] { + return write === 'answer' ? ['questionChanged'] : ['questionChanged', NOT_DONE[write]] +} + +/** The notice a named reason has of its own; undefined leaves the code's words. */ +function reasonParts( + failure: AgentSessionWriteRefusal, + write: AgentSessionWriteKind +): AgentSessionWriteNoticePart[] | undefined { + const words = agentSessionRefusalReasonWords(failure) + if (!words || 'words' in words) { + return undefined + } + if ('fact' in words) { + return write === 'send' || write === 'composer-send' + ? [NOT_DONE[write], { text: agentSessionFailureSentence({ kind: words.fact }, 'rejection') }] + : undefined + } + const said = + words.cause === 'questionChanged' ? questionChangedParts(write) : [words.cause, NOT_DONE[write]] + return words.step ? [...said, words.step] : said +} + export function agentSessionWriteNoticeParts( failure: AgentSessionWriteFailure, write: AgentSessionWriteKind @@ -153,6 +299,10 @@ export function agentSessionWriteNoticeParts( if (failure.kind === 'unconfirmed') { return ['outcomeUnknown'] } + const byReason = reasonParts(failure, write) + if (byReason) { + return byReason + } switch (failure.code) { // The cause is in the chat's own status row. Some restarts can be retried and some need a new // chat, and the code does not say which. @@ -182,7 +332,7 @@ export function agentSessionWriteNoticeParts( // A Stop names the prompt it was pressed under, so it can be refused this way too. case 'agent_session_item_revision_stale': case 'agent_session_already_resolved': - return write === 'answer' ? ['questionChanged'] : ['questionChanged', notDone] + return questionChangedParts(write) case 'agent_session_journal_unreadable': return ['historyUnreadable', notDone] case 'structured_agent_session_unsupported': @@ -203,7 +353,7 @@ export function agentSessionWriteNoticeEnglish( /** English, for a surface without translations. Takes the refusal as the wire gives it; its * message is not read. */ export function agentSessionRefusalNotice( - refusal: Pick, + refusal: Pick, write: AgentSessionWriteKind ): string { return agentSessionWriteNoticeEnglish( diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts new file mode 100644 index 00000000000..83d8faea790 --- /dev/null +++ b/src/shared/agent-session-write-failure.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionRefusalFailure, + parseAgentSessionWriteFailure +} from './agent-session-write-failure' + +const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.' + +function saved(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)) +} + +describe('parseAgentSessionWriteFailure', () => { + // The shape saved before refusals carried a reason; it loads as it was and reads as its code. + it('reads back an entry saved with the code alone, unchanged', () => { + expect( + parseAgentSessionWriteFailure(saved({ kind: 'refused', code: 'agent_session_conflict' })) + ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + expect(parseAgentSessionWriteFailure({ kind: 'failed' })).toEqual({ kind: 'failed' }) + expect( + parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_conflict', + message: HOST_TEXT + }) + ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + }) + + it.each([ + [ + 'a reason', + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'chatStarting' } } + ], + [ + "a rewind's reason", + { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'rewindRefused', rewindReason: 'busy' } + } + ], + [ + "the owner's verdict", + { + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ownerVerdict: 'exited' } + } + ] + ])('keeps %s', (_label, value) => { + expect(parseAgentSessionWriteFailure(saved(value))).toEqual(value) + }) + + // A newer build's reason or verdict is dropped, never guessed; the code's words stand. + it.each([ + [{ reason: 'fromTheFuture' }, undefined], + [{ reason: 'fromTheFuture', ownerVerdict: 'exited' }, { ownerVerdict: 'exited' }], + [{ reason: 'ownerUnproven', ownerVerdict: 'gone' }, { reason: 'ownerUnproven' }], + [{ reason: 'chatStarting' }, undefined] + ])('drops what this build cannot place: %j', (details, kept) => { + expect( + parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + details + }) + ).toEqual({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + ...(kept ? { details: kept } : {}) + }) + }) + + it.each([ + null, + 'The agent was restarting.', + { kind: 'refused' }, + { kind: 'refused', code: 'agent_session_from_the_future' }, + { kind: 'something-else' } + ])('drops %j instead of guessing', (value) => { + expect(parseAgentSessionWriteFailure(value)).toBeUndefined() + }) +}) + +describe('agentSessionRefusalFailure', () => { + // What moves with the owner, the question's answer and the host's words are never kept. + it('keeps only the facts that stay true after a reload', () => { + expect( + agentSessionRefusalFailure({ + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale', currentFence: 3, ownerVerdict: 'exited' } + }) + ).toEqual({ + kind: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + }) + // A prompt's revision and winning answer, and a provider's diagnostic, as a host may send them. + const answered = JSON.parse( + JSON.stringify({ + code: 'agent_session_already_resolved', + details: { + reason: 'promptAlreadyResolved', + currentRevision: 2, + resolution: { state: 'answered', selectedOptionId: 'yes', resolvedBy: 'phone' }, + detail: { text: 'stderr', audience: 'log' } + } + }) + ) + expect(agentSessionRefusalFailure(answered)).toEqual({ + kind: 'refused', + code: 'agent_session_already_resolved', + details: { reason: 'promptAlreadyResolved' } + }) + }) + + it('keeps a code from a newer host without reading its details', () => { + const refusal = JSON.parse('{"code":"agent_session_from_the_future","details":{"reason":"x"}}') + expect(agentSessionRefusalFailure(refusal)).toEqual({ + kind: 'refused', + code: 'agent_session_from_the_future' + }) + }) +}) diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts new file mode 100644 index 00000000000..cd99c6ec0ec --- /dev/null +++ b/src/shared/agent-session-write-failure.ts @@ -0,0 +1,151 @@ +// A chat write that did not happen, as a fact: what the person was doing, and what the host said. +// Saved with a queued message, so it holds only what stays true after a reload; the words are +// chosen from it when it is shown (`agent-session-refusal-notice.ts`). + +import { + readAgentSessionRefusalDetails, + type AgentSessionRefusalReason +} from './agent-session-refusal-details' +import type { AgentSessionRewindReason } from './agent-session-rewind' +import { + isAgentSessionWireRefusalCode, + type AgentSessionOwnerVerdict, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from './agent-session-wire-refusals' + +/** What the person was doing, which decides what the notice says did not happen. `send` keeps + * the message behind a Retry control; `composer-send` puts it back in the composer, as the phone + * does. */ +export type AgentSessionWriteKind = + | 'send' + | 'composer-send' + | 'stop' + | 'stop-task' + | 'stop-tasks' + | 'answer' + | 'option' + | 'command' + | 'goal' + +/** The kind of write an `agentSession.*` call stands for. */ +export function agentSessionWriteKindForMethod( + fingerprintMethod: string, + fields: Record +): AgentSessionWriteKind { + if (fingerprintMethod === 'agentSession.send') { + return 'send' + } + if (fingerprintMethod === 'agentSession.cancel') { + // A background-task stop never asked the agent to stop. + if (fields.scope !== 'background-tasks') { + return 'stop' + } + return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks' + } + if (fingerprintMethod.startsWith('agentSession.respondTo')) { + return 'answer' + } + if (fingerprintMethod === 'agentSession.setOption') { + return 'option' + } + if (fingerprintMethod === 'agentSession.threadGoal') { + return 'goal' + } + return 'command' +} + +/** What a saved refusal keeps beside its reason: facts about the refused operation that stay true + * after a reload. Never the fence, revision or resolution, which move, nor any provider text. */ +type DurableRefusalFacts = { + agent_session_operation_invalid: { rewindReason?: AgentSessionRewindReason } + agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason } + /** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */ + agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict } +} + +const DURABLE_FACT_KEYS: Partial> = { + agent_session_operation_invalid: ['rewindReason'], + agent_session_operation_unknown: ['rewindReason'], + agent_session_ownership_unknown: ['ownerVerdict'] +} satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] } + +export type AgentSessionWriteRefusalDetails = { + reason?: AgentSessionRefusalReason +} & (C extends keyof DurableRefusalFacts ? DurableRefusalFacts[C] : Record) + +/** A refused write: its code, and what the host said about it that outlives the reply. */ +export type AgentSessionWriteRefusal = { + [C in AgentSessionWireRefusalCode]: { + kind: 'refused' + code: C + /** Absent from older hosts and from entries saved before refusals carried a reason. */ + details?: AgentSessionWriteRefusalDetails + } +}[AgentSessionWireRefusalCode] + +/** Why a write did not happen, or that nothing proves it did not. */ +export type AgentSessionWriteFailure = + | AgentSessionWriteRefusal + /** The request failed without a refusal before the host ran it, so nothing is known about why. */ + | { kind: 'failed' } + /** The request failed where the host may already have run it (a timeout, a lost connection, an + * error inside the method). */ + | { kind: 'unconfirmed' } + +/** The refusal as a write keeps it. Details are read against the code, so a reason or verdict + * another build added is dropped and the code's own words stand. */ +function agentSessionWriteRefusal( + code: AgentSessionWireRefusalCode, + details: unknown +): AgentSessionWriteRefusal { + const durable: readonly string[] = ['reason', ...(DURABLE_FACT_KEYS[code] ?? [])] + const kept = Object.fromEntries( + Object.entries(readAgentSessionRefusalDetails(code, details) ?? {}).filter(([key]) => + durable.includes(key) + ) + ) + const refusal = { + kind: 'refused', + code, + ...(Object.keys(kept).length > 0 ? { details: kept } : {}) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `kept` holds only a reason and facts read against `code` and allowed for it above. + return refusal as AgentSessionWriteRefusal +} + +export function agentSessionRefusalFailure( + refusal: Pick +): AgentSessionWriteRefusal { + // A newer host can send a code this client has never heard of; its details mean nothing here. + return isAgentSessionWireRefusalCode(refusal.code) + ? agentSessionWriteRefusal(refusal.code, refusal.details) + : { kind: 'refused', code: refusal.code } +} + +/** A request that threw, from the RPC error code the host answered with (undefined when none came + * back). Only a host that turned it away before running the method proves the write did not + * happen. */ +export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure { + if (code === 'method_not_found' || code === 'method_not_supported') { + return { kind: 'refused', code: 'structured_agent_session_unsupported' } + } + return code === 'invalid_argument' || code === 'unauthorized' + ? { kind: 'refused', code: 'agent_session_operation_invalid' } + : { kind: 'unconfirmed' } +} + +/** A saved failure, or undefined when it is not one this build wrote. */ +export function parseAgentSessionWriteFailure( + value: unknown +): AgentSessionWriteFailure | undefined { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + return undefined + } + if (value.kind === 'failed') { + return { kind: 'failed' } + } + return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code) + ? agentSessionWriteRefusal(value.code, 'details' in value ? value.details : undefined) + : undefined +} diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 61b39d1406e..8df095f47b9 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -1,13 +1,12 @@ +import { readAgentSessionFailureFact, type AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' import { parseAgentSessionWriteFailure, - type AgentSessionWriteFailure -} from './agent-session-refusal-notice' + type AgentSessionWriteFailure, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' -import type { - AgentSessionMutationEnvelope, - AgentSessionWireRefusalCode -} from './agent-session-wire' +import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' @@ -34,11 +33,39 @@ export type StructuredAgentSessionOutboxEntry = { lastFailure?: StructuredAgentSessionAttemptFailure } +/** A host's rejection fact as a message keeps it: never its provider detail, whose person-facing + * words are already in the reason and whose log text is not kept client-side. */ +export type StructuredAgentSessionRejectionFact = Pick< + AgentSessionFailureFact, + 'kind' | 'attachment' +> + /** Kept as the fact, not the words: the Retry row chooses those when it shows the entry. */ export type StructuredAgentSessionAttemptFailure = | AgentSessionWriteFailure /** The host recorded the message and the provider turned it down, with the provider's reason. */ - | { kind: 'rejected'; reason: string | null } + | { kind: 'rejected'; reason: string | null; rejection?: StructuredAgentSessionRejectionFact } + +/** The failure a rejected submission leaves on its message. A fact this build cannot place is + * dropped, leaving the reason. */ +export function structuredAgentSessionRejectedFailure(submission: { + reason: string | null + rejection?: unknown +}): Extract { + const fact = readAgentSessionFailureFact(submission.rejection) + return { + kind: 'rejected', + reason: submission.reason, + ...(fact + ? { + rejection: { + kind: fact.kind, + ...(fact.attachment ? { attachment: fact.attachment } : {}) + } + } + : {}) + } +} function parseStructuredAgentSessionAttemptFailure( value: unknown @@ -51,7 +78,10 @@ function parseStructuredAgentSessionAttemptFailure( 'reason' in value ) { return value.reason === null || typeof value.reason === 'string' - ? { kind: 'rejected', reason: value.reason } + ? structuredAgentSessionRejectedFailure({ + reason: value.reason, + rejection: 'rejection' in value ? value.rejection : undefined + }) : undefined } return parseAgentSessionWriteFailure(value) @@ -119,13 +149,12 @@ export function stageStructuredAgentSessionOutboxEntryForSend( export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, - code: AgentSessionWireRefusalCode, + refusal: AgentSessionWriteRefusal, createOperationId: () => string, retainOperationId = false ): StructuredAgentSessionOutboxEntry { - const refusalState = agentSessionRefusalOperationState(code) if ( - refusalState !== 'settled-rejected' || + agentSessionRefusalOperationState(refusal.code) !== 'settled-rejected' || retainOperationId || entry.state === 'unconfirmed' || entry.retryAfterUnknownSubmittedAt !== null @@ -173,7 +202,7 @@ export function reconcileStructuredAgentSessionOutbox( { ...entry, state: 'rejected' as const, - lastFailure: { kind: 'rejected' as const, reason: submission.reason } + lastFailure: structuredAgentSessionRejectedFailure(submission) } ] } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 2b81c5c8f87..08fad831af2 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -189,6 +189,57 @@ describe('what a refusal shows the user', () => { ).toBe('Your message was not sent.') }) + it("keeps the reason the host named, and says it on the message's Retry row", () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: { + ok: false, + refusal: { + code: 'agent_session_conflict', + message: 'The chat is still starting.', + details: { reason: 'chatStarting' } + } + }, + createOperationId: () => 'unused' + }) + + const lastFailure = disposition.entries[0]?.lastFailure + expect(lastFailure).toEqual({ + kind: 'refused', + code: 'agent_session_conflict', + details: { reason: 'chatStarting' } + }) + expect( + agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(lastFailure!)) + ).toBe( + 'The agent is still starting. Your message was not sent. Wait for the agent to finish starting.' + ) + }) + + it("keeps a rejected message's typed fact without the provider's detail", () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith('An image on this message is empty, so the message was not sent.', { + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'empty' }, + detail: { text: 'image block 2: zero bytes', audience: 'log' } + } + }), + createOperationId: () => 'unused' + }) + + expect(disposition.entries[0]?.lastFailure).toEqual({ + kind: 'rejected', + reason: 'An image on this message is empty, so the message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } } + }) + }) + it('keeps a failed request as a fact, not a transport error string', () => { const disposition = disposeStructuredAgentSessionSendFailure({ entries: [entry], diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index c59563e2c8f..9d55f8b85b8 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -10,16 +10,17 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { - agentSessionRefusalFailure, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts, agentSessionWriteNotDoneParts, type AgentSessionWriteNoticePart } from './agent-session-refusal-notice' +import { agentSessionRefusalFailure } from './agent-session-write-failure' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, + structuredAgentSessionRejectedFailure, type StructuredAgentSessionAttemptFailure, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -162,16 +163,17 @@ export function disposeStructuredAgentSessionSendResult( const refusedIndex = input.entries.findIndex( (candidate) => candidate.clientMessageId === input.entry.clientMessageId ) + const refusal = agentSessionRefusalFailure(result.refusal) const entries = input.entries.map((candidate) => candidate.clientMessageId === input.entry.clientMessageId ? withLastFailure( requeueStructuredAgentSessionSendRefusal( candidate, - result.refusal.code, + refusal, input.createOperationId, input.entry.lastAttemptAt !== null ), - agentSessionRefusalFailure(result.refusal) + refusal ) : candidate ) @@ -221,10 +223,11 @@ export function disposeStructuredAgentSessionSendResult( } if (submission.dispatchState === 'rejected') { return { - entries: replaceEntryState(input, 'rejected', { - kind: 'rejected', - reason: submission.reason - }), + entries: replaceEntryState( + input, + 'rejected', + structuredAgentSessionRejectedFailure(submission) + ), error: null, blockedClientMessageId: input.blockedClientMessageId, retryWithFreshClientMessageId: input.entry.clientMessageId From 1e168abac4c91e25dd20781362ed4ecefdb005dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:25:05 -0700 Subject: [PATCH 101/122] fix(native-chat): retry a refused send under a new id only once its agent is proven gone A send refused because Orca could not tell who owns the chat keeps its operation id, since the first attempt may still land. When the refusal also says the agent process has exited, nothing can run that attempt, so the message moves to its Retry row under a new id instead of holding the queue. The owner's verdict is read as a floor: a saved `exited` is final, and any other saved verdict never changes the id on its own. Only a verdict re-derived from the current lease can raise it to `exited`, and nothing lowers a saved `exited`. Today no host sends a verdict on a send refusal, so nothing a person sees changes; the rule is in place for the saved verdict a reload reads back. --- .../agent-session-refusal-retry.test.ts | 70 +++++++++++++++++++ src/shared/agent-session-refusal-retry.ts | 15 +++- src/shared/structured-agent-session-outbox.ts | 11 ++- 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/shared/agent-session-refusal-retry.test.ts diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts new file mode 100644 index 00000000000..4a436aaf4e6 --- /dev/null +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOwnerVerdict } from './agent-session-wire' +import { agentSessionOwnerVerdictAllowsFreshOperationId } from './agent-session-refusal-retry' +import { parseAgentSessionWriteFailure } from './agent-session-write-failure' +import { + createStructuredAgentSessionOutboxEntry, + requeueStructuredAgentSessionSendRefusal +} from './structured-agent-session-outbox' + +const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'message-1', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 +}) + +/** A refused send as a reloaded entry holds it: through the saved shape and back. */ +function storedOwnershipRefusal(ownerVerdict: AgentSessionOwnerVerdict | undefined) { + const failure = parseAgentSessionWriteFailure( + JSON.parse( + JSON.stringify({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ...(ownerVerdict ? { ownerVerdict } : {}) } + }) + ) + ) + if (failure?.kind !== 'refused') { + throw new Error('the saved refusal did not read back') + } + return failure +} + +function retried(ownerVerdict: AgentSessionOwnerVerdict | undefined) { + return requeueStructuredAgentSessionSendRefusal( + entry, + storedOwnershipRefusal(ownerVerdict), + () => 'message-2' + ) +} + +describe("the owner's verdict decides a retry's operation id only as a floor", () => { + it('lets a retry use a new id once the stored verdict proves nothing runs', () => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited')).toBe(true) + expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2', state: 'rejected' }) + }) + + it.each(['unverifiable', 'live', undefined])( + 'keeps the id for a stored %s verdict, whose operation may still land', + (stored) => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored)).toBe(false) + expect(retried(stored)).toMatchObject({ clientMessageId: 'message-1', state: 'queued' }) + } + ) + + it.each(['unverifiable', 'live', undefined])( + 'moves off a stored %s verdict only when the current lease proves the owner exited', + (stored) => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'exited')).toBe(true) + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'unverifiable')).toBe(false) + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'live')).toBe(false) + } + ) + + it('never lowers a stored exited', () => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'live')).toBe(true) + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'unverifiable')).toBe(true) + }) +}) diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts index 9777cfd42d1..093d3549729 100644 --- a/src/shared/agent-session-refusal-retry.ts +++ b/src/shared/agent-session-refusal-retry.ts @@ -1,4 +1,4 @@ -import type { AgentSessionWireRefusalCode } from './agent-session-wire' +import type { AgentSessionOwnerVerdict, AgentSessionWireRefusalCode } from './agent-session-wire' export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown' @@ -28,3 +28,16 @@ export function agentSessionRefusalOperationState( return 'pending-admission' } } + +/** + * Whether a verdict lets a retry use a new operation id. A stored `exited` is final: nothing runs + * the refused operation, so a new id cannot collide with it. Any other stored verdict is a floor: + * the first operation may still land, so only a verdict re-derived from the current lease can prove + * `exited`, and nothing lowers a stored `exited`. + */ +export function agentSessionOwnerVerdictAllowsFreshOperationId( + stored: AgentSessionOwnerVerdict | undefined, + current?: AgentSessionOwnerVerdict +): boolean { + return stored === 'exited' || current === 'exited' +} diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 8df095f47b9..bbf9cdfc0b3 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -5,7 +5,10 @@ import { type AgentSessionWriteFailure, type AgentSessionWriteRefusal } from './agent-session-write-failure' -import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' +import { + agentSessionOwnerVerdictAllowsFreshOperationId, + agentSessionRefusalOperationState +} from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' @@ -153,8 +156,12 @@ export function requeueStructuredAgentSessionSendRefusal( createOperationId: () => string, retainOperationId = false ): StructuredAgentSessionOutboxEntry { + const refusalSettled = + agentSessionRefusalOperationState(refusal.code) === 'settled-rejected' || + (refusal.code === 'agent_session_ownership_unknown' && + agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict)) if ( - agentSessionRefusalOperationState(refusal.code) !== 'settled-rejected' || + !refusalSettled || retainOperationId || entry.state === 'unconfirmed' || entry.retryAfterUnknownSubmittedAt !== null From 12e30c74b3535562b65d4271ae20f81797c3ffc1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:43:28 -0700 Subject: [PATCH 102/122] fix(native-chat): say a /clear that never finished did not finish, instead of that it cleared the chat A send into a chat whose /clear started but never committed was refused as if the conversation had been cleared: "This conversation has been cleared. Your message was not sent. Open the current conversation to continue." The clear never finished, so its new conversation may not exist and there is nothing to open. That refusal now has its own reason and reads "The last /clear didn't finish. Your message was not sent. Start a new chat to continue.", which is the only way on today. Its message for released clients says the same: "The last /clear didn't finish. Start a new chat to continue." Only a committed /clear still says the conversation was cleared. --- ...tructured-agent-session-send-block.test.ts | 65 +++++++++++++++++++ ...ructured-agent-session-send-preparation.ts | 30 +++++---- .../agent-session-write-notice-text.ts | 2 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-notice.test.ts | 6 ++ src/shared/agent-session-refusal-notice.ts | 3 + 13 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts new file mode 100644 index 00000000000..0a1895941b7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionConversationCommandRecord } from '../../../shared/agent-session-conversation-command' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' + +function withCommand(command: AgentSessionConversationCommandRecord) { + return { ...agentSessionRecordFixture(), conversationCommand: command } +} + +const COMMAND = { operationId: 'operation-1', callerKey: 'client-1', runtimeFence: 7 } + +describe('a send refused by the conversation command it follows', () => { + it('says a /clear that never committed did not finish, not that the chat was cleared', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'unknown', + phase: 'prepared', + replacementSessionId: 'clear-replacement-1' + }) + ) + + expect(blocked?.refusal).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'clearUnconfirmed' }, + message: "The last /clear didn't finish. Start a new chat to continue." + }) + }) + + it('says a committed /clear cleared the conversation', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'completed', + phase: 'committed', + replacementSessionId: 'clear-replacement-1' + }) + ) + + expect(blocked?.refusal).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + }) + }) + + it('keeps an unconfirmed /compact as an unconfirmed command', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) + ) + + expect(blocked?.refusal).toMatchObject({ + details: { reason: 'conversationCommandUnconfirmed' } + }) + }) + + it('lets a send follow a /clear whose new conversation failed to start', () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'clear', state: 'completed', phase: 'committed' }) + ) + ).toBeNull() + }) +}) 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 b9acac3d812..d60ce592e3e 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 @@ -35,17 +35,25 @@ export function structuredAgentSessionSendBlock( ) { return { ok: false, - refusal: command.replacementSessionId - ? refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCleared' }, - 'This conversation has been cleared. Use the current conversation.' - ) - : refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCommandUnconfirmed' }, - 'The conversation operation is unconfirmed.' - ) + refusal: + command.phase === 'committed' && command.replacementSessionId + ? refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCleared' }, + 'This conversation has been cleared. Use the current conversation.' + ) + : // A prepared /clear names its replacement before that conversation exists. + command.command === 'clear' + ? refuse( + 'agent_session_operation_invalid', + { reason: 'clearUnconfirmed' }, + "The last /clear didn't finish. Start a new chat to continue." + ) + : refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandUnconfirmed' }, + 'The conversation operation is unconfirmed.' + ) } } return null diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 02528cc862c..b5482c48d23 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -49,6 +49,8 @@ const SENTENCES: Record string> = { 'components.native-chat.writeNotice.openCurrentConversation', COPY.openCurrentConversation ), + clearUnfinished: () => + translate('components.native-chat.writeNotice.clearUnfinished', COPY.clearUnfinished), commandRunning: () => translate('components.native-chat.writeNotice.commandRunning', COPY.commandRunning), waitForCommand: () => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e65c931a7de..b63e9c38661 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2731,6 +2731,7 @@ "notDoneStopTask": "The background task wasn't stopped.", "notDoneStopTasks": "The background tasks weren't stopped.", "openCurrentConversation": "Open the current conversation to continue.", + "clearUnfinished": "The last /clear didn't finish.", "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f532e33df6d..5d7b93c490f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17485,6 +17485,7 @@ "recordFailed": "Orca couldn't record it in this chat's history.", "conversationCleared": "This conversation has been cleared.", "openCurrentConversation": "Open the current conversation to continue.", + "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", "waitForCommand": "Wait for the /compact or /clear to finish.", "agentStarting": "The agent is still starting.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8f03d1042e9..cf8d9646e64 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14945,6 +14945,7 @@ "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", "conversationCleared": "Esta conversación se borró.", "openCurrentConversation": "Abra la conversación actual para continuar.", + "clearUnfinished": "El último /clear no terminó.", "commandRunning": "Todavía se está ejecutando un /compact o /clear.", "waitForCommand": "Espere a que termine el /compact o /clear.", "agentStarting": "El agente todavía se está iniciando.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 742a259f98f..547b7209e2c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17428,6 +17428,7 @@ "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", "conversationCleared": "Cette conversation a été effacée.", "openCurrentConversation": "Ouvrez la conversation actuelle pour continuer.", + "clearUnfinished": "Le dernier /clear ne s'est pas terminé.", "commandRunning": "Un /compact ou /clear est encore en cours.", "waitForCommand": "Attendez la fin du /compact ou /clear.", "agentStarting": "L'agent est encore en cours de démarrage.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d48a0033de8..5567502e9e3 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17364,6 +17364,7 @@ "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", "conversationCleared": "この会話はクリアされました。", "openCurrentConversation": "続けるには、現在の会話を開いてください。", + "clearUnfinished": "前回の /clear は完了しませんでした。", "commandRunning": "/compact または /clear がまだ実行中です。", "waitForCommand": "/compact または /clear が終わるまでお待ちください。", "agentStarting": "Agent はまだ起動中です。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b59bf5aa9f5..f36ce09e8a0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17364,6 +17364,7 @@ "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", "conversationCleared": "이 대화는 지워졌습니다.", "openCurrentConversation": "계속하려면 현재 대화를 여세요.", + "clearUnfinished": "마지막 /clear가 완료되지 않았습니다.", "commandRunning": "/compact 또는 /clear가 아직 실행 중입니다.", "waitForCommand": "/compact 또는 /clear가 끝날 때까지 기다리세요.", "agentStarting": "에이전트가 아직 시작 중입니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 84da5230005..1a2e3c63f0f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17329,6 +17329,7 @@ "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", "conversationCleared": "此对话已被清除。", "openCurrentConversation": "请打开当前对话以继续。", + "clearUnfinished": "上一次 /clear 未完成。", "commandRunning": "/compact 或 /clear 仍在运行。", "waitForCommand": "请等待 /compact 或 /clear 完成。", "agentStarting": "智能体仍在启动。", diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index e5a9be90122..dafcd93d20a 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -24,6 +24,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'journalWriteFailed', // The conversation's state 'conversationCleared', + /** A /clear that never committed; its replacement conversation may not exist. */ + 'clearUnconfirmed', 'conversationCommandUnconfirmed', 'conversationCommandInFlight', 'handoffInFlight', diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 8bd846fcf42..dfadb6f65e2 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -387,6 +387,12 @@ describe('the notice for every reason a host names', () => { 'send', 'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.' ], + [ + 'agent_session_operation_invalid', + 'clearUnconfirmed', + 'send', + "The last /clear didn't finish. Your message was not sent. Start a new chat to continue." + ], [ 'agent_session_operation_invalid', 'turnActive', diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 45d65f9814f..4bcfac742c7 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -50,6 +50,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { recordFailed: "Orca couldn't record it in this chat's history.", conversationCleared: 'This conversation has been cleared.', openCurrentConversation: 'Open the current conversation to continue.', + clearUnfinished: "The last /clear didn't finish.", commandRunning: 'A /compact or /clear is still running.', waitForCommand: 'Wait for the /compact or /clear to finish.', agentStarting: 'The agent is still starting.', @@ -163,6 +164,8 @@ const REASON_WORDS = { 'goElsewhere', 'openCurrentConversation' ), + // Nothing settles an unfinished /clear yet, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), // A /clear or /compact whose outcome the host never settled; only the host resolves it. conversationCommandUnconfirmed: codeWords('hostFinding'), conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), From 574e2accf8a5058e0217f167279f4bcbe6253e50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:01:59 -0700 Subject: [PATCH 103/122] fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send the provider's history proves it never received. Nobody failed that send, but the verdict table treated it as a failure. Each rejection kind now has its verdict in one exhaustive table, so a new kind does not compile until its verdict is chosen; no verdict for a withdrawal, a host restart, a chat close, or this lost send. A rejection whose kind this build cannot place, such as one a newer host added, now reads as undelivered with no verdict instead of falling back to the reason beside it: all it proves is that the message did not happen. The host keeps such a fact's kind when it reads the row back, rather than dropping it and letting the reason decide. Only kinds that can be why a message was not sent may reject one, by type: compaction, cancel/answer confirmation and provider-retry kinds stay on status rows. The one dispatch-row builder takes its input from the type that makes a rejected row carry its fact. --- .../claude-structured-dispatch-content.ts | 8 +- .../claude-structured-session-adapter.ts | 4 +- ...claude-structured-session-startup-state.ts | 4 +- .../claude/claude-structured-session-state.ts | 4 +- src/main/codex/codex-structured-turn-start.ts | 4 +- .../journal-dispatch-reducer.ts | 22 ++++- .../journal-reducer.test.ts | 42 ++++++---- .../journal-restart-reconciliation.test.ts | 7 ++ .../journal-row-builders.ts | 50 +++--------- ...structured-agent-session-adapter-router.ts | 4 +- .../structured-agent-session-adapter.ts | 6 +- ...gent-session-dead-generation-settlement.ts | 4 +- .../structured-agent-session-delivery-loop.ts | 4 +- .../structured-agent-session-failure-text.ts | 16 ++-- .../structured-agent-session-host-types.ts | 4 +- ...gent-session-provider-child-record.test.ts | 7 +- .../agent-session-failure-words.test.ts | 21 +++-- src/shared/agent-session-failure-words.ts | 44 ++++++---- src/shared/agent-session-failure.ts | 41 +++++++++- src/shared/agent-session-journal-types.ts | 4 +- ...d-agent-session-dispatch-rejection.test.ts | 60 +++++++++++--- ...ctured-agent-session-dispatch-rejection.ts | 81 +++++++++++-------- 22 files changed, 272 insertions(+), 169 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 1b97849a0e9..035b0a59492 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -6,7 +6,7 @@ import type { NativeChatBlock } from '../../shared/native-chat-types' import { agentSessionFailureFact, type AgentSessionAttachmentProblem, - type AgentSessionFailureFact + type SubmissionRejectionFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords, @@ -18,9 +18,9 @@ import { claudeRecord } from './claude-structured-item-translation' /** Orca refused the message's content, as opposed to failing to read an attachment. */ export class ClaudeDispatchContentError extends Error { /** What was wrong, typed where the check saw it; `message` stays for logs. */ - readonly failure: AgentSessionFailureFact + readonly failure: SubmissionRejectionFact - constructor(message: string, failure: AgentSessionFailureFact) { + constructor(message: string, failure: SubmissionRejectionFact) { super(message) this.name = 'ClaudeDispatchContentError' this.failure = failure @@ -36,7 +36,7 @@ function attachmentError(message: string, attachment: AgentSessionAttachmentProb /** A message Claude rejected, in the words that name Claude and its legacy markers. */ export function claudeDispatchRejection( - failure: AgentSessionFailureFact + failure: SubmissionRejectionFact ): AgentJournalDispatchRejection { return agentSessionFailureWords(failure, { surface: 'rejection', diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 0c400fb818d..d347a6197eb 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,4 @@ -import type { AgentSessionFailureFact } from '../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' import type { AgentSessionAcquisition, @@ -121,7 +121,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda /** 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 => { + awaitStarted = async (sessionId: string): Promise => { const session = this.sessions.get(sessionId) if (!session) { return diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index 5717405a2c3..cf44bd984ab 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -3,7 +3,7 @@ // 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 type { AgentSessionFailureFact } from '../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import type { ClaudeSession } from './claude-structured-session-state' @@ -25,7 +25,7 @@ export function createClaudeSessionStartup(): ClaudeSessionStartup { return { state: 'pending', failure: null, settled: ended, end } } -export function claudeStartupFailureFact(session: ClaudeSession): AgentSessionFailureFact | null { +export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { return session.startup.state === 'failed' ? providerStartupFailureFact(session.startup.failure ?? undefined) : null diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 02e84c6a05e..a0b0f321679 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,5 +1,5 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { AgentSessionFailureFact } from '../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -68,7 +68,7 @@ export type ClaudeStructuredSessionEvent = type: 'ended' sessionId: string reason: string - failure?: AgentSessionFailureFact + failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' fence?: number diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 42422f7fee3..0c46969d62f 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -3,7 +3,7 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { AgentSessionFailureFact } from '../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' @@ -122,7 +122,7 @@ export async function startCodexTurn( } /** A message Codex rejected, in the words that name Codex and its legacy markers. */ -function codexDispatchRejection(failure: AgentSessionFailureFact): AgentJournalDispatchRejection { +function codexDispatchRejection(failure: SubmissionRejectionFact): AgentJournalDispatchRejection { return agentSessionFailureWords(failure, { surface: 'rejection', agentName: TUI_AGENT_DISPLAY_NAMES.codex, diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 4e565e4ae50..470ed3dfcb2 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -1,7 +1,10 @@ // How a `dispatch` row settles its submission. Field by field, so a key the row gains must be // copied here to reach any reader. -import { readAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' @@ -22,9 +25,12 @@ export function applyJournalDispatchRow( submission.dispatchState = row.state submission.providerItemId = row.providerItemId submission.reason = row.reason - // Read, not copied: a row from disk may carry a fact this build cannot place. + // Read where it can be placed; a kind it cannot place is kept as written, so the classifier + // still knows a fact was there without this build claiming what it says. const rejection = - row.state === 'rejected' ? readAgentSessionFailureFact(row.rejection) : undefined + row.state === 'rejected' + ? (readAgentSessionFailureFact(row.rejection) ?? unreadFailureFact(row.rejection)) + : undefined if (rejection) { submission.rejection = rejection } else { @@ -50,3 +56,13 @@ export function applyJournalDispatchRow( acceptedAt: row.ts }) } + +function unreadFailureFact(value: unknown): UnreadAgentSessionFailureFact | undefined { + return typeof value === 'object' && + value !== null && + 'kind' in value && + typeof value.kind === 'string' && + value.kind + ? { kind: value.kind } + : undefined +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index bef520d0ad6..77773368991 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -9,6 +9,10 @@ import type { AgentJournalItemIdentity, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_QUEUE_FULL +} from '../../../shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { applyJournalRow, @@ -320,23 +324,31 @@ describe('submission and dispatch state machine', () => { expect(doubt.submissions.get('cm_2')).not.toHaveProperty('rejection') }) - it('drops a rejection fact it cannot place but keeps the row', () => { - const rejected: JournalRow = { - kind: 'dispatch', - clientMessageId: 'cm_1', - state: 'rejected', - providerItemId: null, - reason: 'Not sent.', - ...base(2) + it('keeps only the kind of a rejection fact it cannot place, so it reads as no verdict', () => { + const rejected = (rejection: unknown): JournalRow => { + const row: JournalRow = { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_QUEUE_FULL, + ...base(2) + } + // A row read from disk carries whatever the host that wrote it did. + return Object.assign(row, { rejection }) } - // A newer host's kind, as a row read from disk would carry it. - Object.assign(rejected, { rejection: { kind: 'futureKind' } }) - const state = fold([submission, rejected]) - expect(state.submissions.get('cm_1')).toMatchObject({ - dispatchState: 'rejected', - reason: 'Not sent.' + // A newer host's kind: the marker beside it must not decide. + const newer = fold([submission, rejected({ kind: 'futureKind', detail: 'x' })]) + const settled = newer.submissions.get('cm_1') + expect(settled).toMatchObject({ dispatchState: 'rejected', rejection: { kind: 'futureKind' } }) + expect(settled && classifyDispatchRejection(settled)).toEqual({ + category: 'undelivered', + verdict: null }) - expect(state.submissions.get('cm_1')).not.toHaveProperty('rejection') + // Not a fact at all: dropped, leaving the reason. + const malformed = fold([submission, rejected('queueFull')]).submissions.get('cm_1') + expect(malformed).not.toHaveProperty('rejection') + expect(malformed && classifyDispatchRejection(malformed)).toMatchObject({ kind: 'queueFull' }) }) it('does not give a newer identical echo to a legacy unknown write failure', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index b484ec384ed..4428e13953e 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -17,6 +17,7 @@ import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submi import { createTrackedJournalOpener } from './journal-store-test-open' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -130,6 +131,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { .reason ) expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) + // Nobody failed: the crash stranded it before the provider took it. + expect(submission && classifyDispatchRejection(submission)).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) }) it('leaves a submission unknown while the provider reports a turn in flight', async () => { 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 b8b1e06961f..ee56f28f8a2 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 @@ -1,6 +1,4 @@ -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { - AgentJournalDispatchState, AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, @@ -79,19 +77,17 @@ export function journalDispatchRowBuilder( ): RowBuilder { const providerItemId = input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null - return (seq, ts) => - buildJournalDispatchRow({ - state: state(), - clientMessageId: input.clientMessageId, - dispatchState: input.state, - providerItemId, - reason: boundedDispatchReason(input), - rejection: input.state === 'rejected' ? input.rejection : undefined, - seq, - fence: input.fence, - ts, - recovered: input.recovered - }) + // The only dispatch-row builder: its input type is what makes a rejected row carry its fact. + return (seq, ts) => ({ + kind: 'dispatch', + clientMessageId: input.clientMessageId, + state: input.state, + providerItemId, + reason: boundedDispatchReason(input), + ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...journalRowBase(state().epoch, seq, input.fence, ts), + ...(input.recovered ? { recovered: input.recovered } : {}) + }) } /** `reason` is the only unbounded field written by Orca's own code: a provider error is @@ -282,27 +278,3 @@ export function buildJournalSubmissionRow(input: { ...(input.handoverRecorded ? { handoverRecorded: true } : {}) } } - -export function buildJournalDispatchRow(input: { - state: JournalReducerState - clientMessageId: string - dispatchState: AgentJournalDispatchState - providerItemId: string | null - reason: string | null - rejection?: AgentSessionFailureFact - seq: number - fence: number - ts: number - recovered?: true -}): JournalDispatchRow { - return { - kind: 'dispatch', - clientMessageId: input.clientMessageId, - state: input.dispatchState, - providerItemId: input.providerItemId, - reason: input.reason, - ...(input.rejection ? { rejection: input.rejection } : {}), - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), - ...(input.recovered ? { recovered: input.recovered } : {}) - } -} 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 3200e0ba2c0..8da23d880ef 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 @@ -1,4 +1,4 @@ -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionAccountHome, @@ -123,7 +123,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 a0bdc86d2b4..07ec6d62b3a 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 @@ -33,7 +33,7 @@ import { isAgentSessionWireRefusalCode, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -164,7 +164,7 @@ export type StructuredAgentSessionEndedEvent = { reason: string /** Why it ended, as the adapter knows it: the provider's exit with its own diagnostic, or an * Orca fault. Absent reads as a provider exit with nothing to add. */ - failure?: AgentSessionFailureFact + failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string @@ -324,7 +324,7 @@ export type StructuredAgentSessionAdapter = { /** Resolves once a session published before it proved its start has proven it, failed, or been * 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 + 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-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 26de13fb900..5072d14748b 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 @@ -1,7 +1,7 @@ import { agentSessionFailureFact, MAX_PROVIDER_DIAGNOSTIC_CHARS, - type AgentSessionFailureFact + type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { @@ -101,7 +101,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { pendingSubmissionReason: string showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ - exitFailure?: AgentSessionFailureFact + exitFailure?: SubmissionRejectionFact /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's 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 9f513cecee8..7f8fba84320 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 @@ -11,7 +11,7 @@ import { agentSessionFailureFact, - type AgentSessionFailureFact + type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, @@ -161,7 +161,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: AgentSessionFailureFact | null + startFailure: SubmissionRejectionFact | null ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 0eba1288d97..c0c2172230d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -4,7 +4,7 @@ import { agentSessionFailureFact, providerDiagnosticOf, - type AgentSessionFailureFact, + type SubmissionRejectionFact, type AgentSessionFailureKind, type ProviderDiagnostic } from '../../../shared/agent-session-failure' @@ -62,7 +62,7 @@ export function providerExitObserved(error: unknown, depth = 0): boolean { /** A start that did not land. A refusal the adapter typed keeps its situation, and an exit the * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ -export function providerStartupFailureFact(cause?: unknown): AgentSessionFailureFact { +export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { const typed = typedStartRefusal( cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined ) @@ -78,8 +78,8 @@ export function providerStartupFailureFact(cause?: unknown): AgentSessionFailure /** A child that ended before it proved its start: an exit is a start that failed, keeping the * provider's diagnostic; an Orca fault or a typed start refusal stays what it was. */ function startupFailureFromExit( - failure: AgentSessionFailureFact | undefined -): AgentSessionFailureFact { + failure: SubmissionRejectionFact | undefined +): SubmissionRejectionFact { if (!failure || failure.kind === 'providerExited') { return agentSessionFailureFact('providerStartFailed', { detail: failure?.detail }) } @@ -91,7 +91,7 @@ function startupFailureFromExit( * now. */ function refusedStartFailureFact( cause: Extract -): AgentSessionFailureFact { +): SubmissionRejectionFact { const { refusal, diagnostic } = cause const reason = refusal.details?.reason const typed = typedStartRefusal(reason) @@ -115,14 +115,14 @@ export type StructuredAgentSessionStartFailureCause = /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ | { error: unknown } /** The child ended before it proved its start, as its ended event told it. */ - | { exit: AgentSessionFailureFact | undefined } + | { exit: SubmissionRejectionFact | undefined } /** The provider exited while starting; only its words are known, and the caller names their * audience. */ | { diagnostic: ProviderDiagnostic | undefined } /** Orca's own fault; its error belongs in the log. */ | { hostFault: true } /** Already typed where it was observed. */ - | { failure: AgentSessionFailureFact } + | { failure: SubmissionRejectionFact } /** A start failure's row repeats the sentence its rejected messages carry: both are about the * messages the start was for. */ @@ -131,7 +131,7 @@ export type StructuredAgentSessionStartFailureWords = AgentJournalDispatchReject /** The fact a failed start records, for a writer that words it on its own surface. */ export function structuredAgentSessionStartFailureFact( cause: StructuredAgentSessionStartFailureCause -): AgentSessionFailureFact { +): SubmissionRejectionFact { if ('refusal' in cause) { return refusedStartFailureFact(cause) } 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 0849e3af95b..88aabd8238b 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,4 @@ -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' 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' @@ -63,7 +63,7 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ reason: string | null /** What the chat records about this end; absent reads as a provider exit with no detail. */ - failure?: AgentSessionFailureFact + failure?: SubmissionRejectionFact duringStartup: boolean /** Where the conversation's journal stood when the child ended, to order the end against a * message's acceptance. */ 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 466dc32bb95..4c258ae347e 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 @@ -15,7 +15,8 @@ import type { import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { agentSessionFailureFact, - type AgentSessionFailureFact + type AgentSessionFailureFact, + type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { agentSessionFailureSentence, @@ -367,7 +368,7 @@ describe('settling an earlier child before the next one takes its message', () = const START_EXIT = 'claude stream-json exited (code 1)' const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' -const START_FAILURE: AgentSessionFailureFact = { +const START_FAILURE: SubmissionRejectionFact = { kind: 'providerStartFailed', detail: { text: START_EXIT, audience: 'log' } } @@ -379,7 +380,7 @@ describe('a published child that dies while it proves its start', () => { 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() + const settled = deferred() adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } await restartHost() acquire.mockImplementation(spawnStartingChild) diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 6aab0dfdacf..13f24956ccf 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS, AGENT_SESSION_FAILURE_KINDS, + isSubmissionRejectionFact, readAgentSessionFailureFact, type AgentSessionFailureFact, - type AgentSessionFailureKind + type AgentSessionFailureKind, + type SubmissionRejectionKind } from './agent-session-failure' import { agentSessionFailureSentence, @@ -23,7 +25,7 @@ import { const ORCA_INTERNAL = /\b[a-z]+_[a-z_]+\b|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection'] -const LEGACY_MARKER_KINDS: ReadonlySet = new Set([ +const LEGACY_MARKER_KINDS: ReadonlySet = new Set([ 'cancelled', 'writeFailed', 'queueFull' @@ -69,22 +71,19 @@ describe('the words written beside a failure fact', () => { expect([fact, sentence]).toEqual([fact, expect.stringMatching(/[^.]\.$/)]) expect(sentence).not.toMatch(/\.\./) expect(sentence).not.toMatch(ORCA_INTERNAL) - if (surface === 'rejection' && LEGACY_MARKER_KINDS.has(kind)) { - continue - } const context = { agentName, command, provider: 'claude' } as const - const written = - surface === 'row' - ? agentSessionFailureWords(fact, { ...context, surface }).text - : agentSessionFailureWords(fact, { ...context, surface }).reason - expect(written).toBe(sentence) + if (surface === 'row') { + expect(agentSessionFailureWords(fact, { ...context, surface }).text).toBe(sentence) + } else if (isSubmissionRejectionFact(fact) && !LEGACY_MARKER_KINDS.has(fact.kind)) { + expect(agentSessionFailureWords(fact, { ...context, surface }).reason).toBe(sentence) + } } } }) }) it('writes the legacy markers released clients hide, byte for byte, on a rejection', () => { - const reason = (kind: AgentSessionFailureKind, provider?: 'claude' | 'codex') => + const reason = (kind: SubmissionRejectionKind, provider?: 'claude' | 'codex') => agentSessionFailureWords({ kind }, { surface: 'rejection', provider }).reason expect(reason('cancelled')).toBe(DISPATCH_REJECTED_CANCELLED) expect(DISPATCH_REJECTED_CANCELLED).toBe('provider_cancelled_before_start') diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 74c2828eb78..229bfaacd9e 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -6,12 +6,15 @@ // provider's words reach the sentence only when the provider wrote them for a person. The table // is also the English default for a client that chooses its own copy from the fact. -import type { - AgentSessionAttachmentProblem, - AgentSessionAttachmentProblemReason, - AgentSessionFailureFact, - AgentSessionFailureKind, - ProviderDiagnostic +import { + isSubmissionRejectionFact, + type AgentSessionAttachmentProblem, + type AgentSessionAttachmentProblemReason, + type AgentSessionFailureFact, + type AgentSessionFailureKind, + type ProviderDiagnostic, + type SubmissionRejectionFact, + type SubmissionRejectionKind } from './agent-session-failure' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { @@ -36,7 +39,7 @@ export type AgentSessionFailureRowWords = { * hide, the legacy marker) they print, and the fact newer ones read. */ export type AgentJournalDispatchRejection = { reason: AgentSessionFailureSentence - rejection: AgentSessionFailureFact + rejection: SubmissionRejectionFact } /** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. */ @@ -212,7 +215,7 @@ export function agentSessionFailureSentence( /** The markers released clients hide, for the rejections that had one before rows carried a fact. * A write failure is the bare marker: its error belongs in the log. */ const LEGACY_REJECTION_MARKERS: Partial< - Record string | undefined> + Record string | undefined> > = { cancelled: () => DISPATCH_REJECTED_CANCELLED, writeFailed: () => DISPATCH_REJECTED_WRITE_FAILED, @@ -231,20 +234,27 @@ export function agentSessionFailureWords( ): AgentSessionFailureRowWords /** The words a message rejected for this fact records. */ export function agentSessionFailureWords( - fact: AgentSessionFailureFact, + fact: SubmissionRejectionFact, context: AgentSessionFailureWordsContext & { surface: 'rejection' } ): AgentJournalDispatchRejection export function agentSessionFailureWords( fact: AgentSessionFailureFact, context: AgentSessionFailureWordsContext & { surface: AgentSessionFailureSurface } ): AgentSessionFailureRowWords | AgentJournalDispatchRejection { + if (context.surface === 'row') { + return { text: branded(agentSessionFailureSentence(fact, 'row', context)), failure: fact } + } + // The rejection overload admits only these; a caller that got past the types is Orca's bug. + if (!isSubmissionRejectionFact(fact)) { + throw new Error(`agent session failure kind ${fact.kind} cannot reject a message`) + } const words = - (context.surface === 'rejection' - ? LEGACY_REJECTION_MARKERS[fact.kind]?.(context) - : undefined) ?? agentSessionFailureSentence(fact, context.surface, context) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the one constructor of the brand; `words` came from the table or a legacy marker for `fact`. - const sentence = words as AgentSessionFailureSentence - return context.surface === 'row' - ? { text: sentence, failure: fact } - : { reason: sentence, rejection: fact } + LEGACY_REJECTION_MARKERS[fact.kind]?.(context) ?? + agentSessionFailureSentence(fact, 'rejection', context) + return { reason: branded(words), rejection: fact } +} + +function branded(words: string): AgentSessionFailureSentence { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `agentSessionFailureWords` calls this, with words from the table or a legacy marker for its fact. + return words as AgentSessionFailureSentence } diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 55953a7e99c..8f280456c84 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -48,6 +48,28 @@ export function isAgentSessionFailureKind(value: unknown): value is AgentSession return typeof value === 'string' && AGENT_SESSION_FAILURE_KINDS.some((kind) => kind === value) } +/** Kinds only a status row reports: none is ever why a message was not sent. */ +const STATUS_ROW_ONLY_FAILURE_KINDS = [ + 'compactionFailed', + 'compactionUnconfirmed', + 'cancelUnconfirmed', + 'answerUnconfirmed', + 'providerRetrying' +] as const satisfies readonly AgentSessionFailureKind[] + +/** Why a message was not sent. A new failure kind is one of these until listed above. */ +export type SubmissionRejectionKind = Exclude< + AgentSessionFailureKind, + (typeof STATUS_ROW_ONLY_FAILURE_KINDS)[number] +> + +export function isSubmissionRejectionKind(value: unknown): value is SubmissionRejectionKind { + return ( + isAgentSessionFailureKind(value) && + !STATUS_ROW_ONLY_FAILURE_KINDS.some((statusOnly) => statusOnly === value) + ) +} + /** `person`: written by the provider for whoever reads the chat, shown inline. `log`: a stderr * tail or exit status, shown only behind Details. */ export type ProviderDiagnosticAudience = 'person' | 'log' @@ -100,6 +122,19 @@ export type AgentSessionFailureFact = { retry?: AgentSessionProviderRetry } +/** A fact as a row stores it: its kind may be one a newer host added, so only + * `readAgentSessionFailureFact` or the rejection classifier may place it. */ +export type UnreadAgentSessionFailureFact = { kind: string } + +/** A fact a rejected message may carry. */ +export type SubmissionRejectionFact = AgentSessionFailureFact & { kind: SubmissionRejectionKind } + +export function isSubmissionRejectionFact( + fact: AgentSessionFailureFact +): fact is SubmissionRejectionFact { + return isSubmissionRejectionKind(fact.kind) +} + /** Null for empty text, so a writer never records a detail with nothing in it. */ export function providerDiagnostic( text: string, @@ -109,15 +144,15 @@ export function providerDiagnostic( return bounded ? { text: bounded, audience } : undefined } -export function agentSessionFailureFact( - kind: AgentSessionFailureKind, +export function agentSessionFailureFact( + kind: TKind, extra: { detail?: ProviderDiagnostic refusal?: AgentSessionRefusalReference attachment?: AgentSessionAttachmentProblem retry?: AgentSessionProviderRetry } = {} -): AgentSessionFailureFact { +): AgentSessionFailureFact & { kind: TKind } { // Re-bounded here, so no writer can store more than the cap however it built the detail. const detail = extra.detail ? providerDiagnostic(extra.detail.text, extra.detail.audience) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 65a0e6c3288..09907a67c41 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -7,7 +7,7 @@ // rewritten in place, so a host that cannot read a row refuses to write the // journal rather than skipping or compacting past it. -import type { AgentSessionFailureFact } from './agent-session-failure' +import type { UnreadAgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureRowWords } from './agent-session-failure-words' import type { AgentType } from './agent-status-types' import type { AgentSessionQuestionAnswer } from './agent-session-question-answer' @@ -375,7 +375,7 @@ export type AgentJournalSubmission = { * older clients already recognise. On `unknown`, the doubt marker. */ reason: string | null /** On `rejected`, why, typed; absent on rows from older hosts. */ - rejection?: AgentSessionFailureFact + rejection?: UnreadAgentSessionFailureFact submittedAt: number resolvedAt: number | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index a8a5cd27fa8..b74150bcaea 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_FAILURE_KINDS, agentSessionFailureFact } from './agent-session-failure' +import { + AGENT_SESSION_FAILURE_KINDS, + agentSessionFailureFact, + isSubmissionRejectionKind +} from './agent-session-failure' import { agentSessionFailureWords } from './agent-session-failure-words' import { classifyDispatchRejection, @@ -16,7 +20,7 @@ describe('classifyDispatchRejection', () => { [DISPATCH_REJECTED_CANCELLED, 'withdrawn', null, 'cancelled'], [DISPATCH_REJECTED_HOST_RESTARTED, 'undelivered', null, 'hostRestarted'], [DISPATCH_REJECTED_PROVIDER_CLOSED, 'undelivered', null, 'chatClosed'], - ['not_delivered', 'undelivered', 'failure', 'notDelivered'], + ['not_delivered', 'undelivered', null, 'notDelivered'], [DISPATCH_REJECTED_QUEUE_FULL, 'transport', 'failure', 'queueFull'], [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'transport', 'failure', 'queueFull'], ['provider_write_failed: broken pipe', 'transport', 'failure', 'writeFailed'], @@ -36,16 +40,37 @@ describe('classifyDispatchRejection', () => { }) }) - it('fails the send for every kind but a withdrawal, a host restart, or a closed chat', () => { - for (const kind of AGENT_SESSION_FAILURE_KINDS) { + it('fails the send for every kind but a withdrawal, a restart, a close, or a lost send', () => { + const noOneFailed = ['cancelled', 'hostRestarted', 'chatClosed', 'notDelivered'] + for (const kind of AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind)) { const verdict = classifyDispatchRejection({ reason: 'x', rejection: { kind } }).verdict - expect([kind, verdict]).toEqual([ - kind, - kind === 'cancelled' || kind === 'hostRestarted' || kind === 'chatClosed' ? null : 'failure' - ]) + expect([kind, verdict]).toEqual([kind, noOneFailed.includes(kind) ? null : 'failure']) } }) + it.each([ + ['cancelled', 'withdrawn', null], + ['hostRestarted', 'undelivered', null], + ['chatClosed', 'undelivered', null], + ['notDelivered', 'undelivered', null], + ['writeFailed', 'transport', 'failure'], + ['queueFull', 'transport', 'failure'] + ] as const)('reads the written %s rejection as %s, verdict %s', (kind, category, verdict) => { + const written = agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection', + provider: 'claude' + }) + expect(classifyDispatchRejection(written)).toEqual({ category, verdict, kind }) + }) + + it('reads a legacy not_delivered row with no fact as a send nobody failed', () => { + expect(classifyDispatchRejection({ reason: 'not_delivered' })).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) + }) + it('reads the typed fact over the sentence beside it', () => { expect( classifyDispatchRejection({ @@ -72,11 +97,20 @@ describe('classifyDispatchRejection', () => { } }) - it('falls back to the reason when a newer host wrote a kind this build does not know', () => { - const rejection = JSON.parse('{"kind":"futureKind"}') - expect( - classifyDispatchRejection({ reason: DISPATCH_REJECTED_CANCELLED, rejection }) - ).toMatchObject({ category: 'withdrawn', verdict: null }) + it('gives a fact it cannot place no verdict, whatever the reason beside it says', () => { + // A newer host's kind, a status-row kind, and a fact with no kind at all. + for (const rejection of [{ kind: 'futureKind' }, { kind: 'providerRetrying' }, {}]) { + for (const reason of [ + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_CANCELLED, + 'Refused.' + ]) { + expect(classifyDispatchRejection({ reason, rejection })).toEqual({ + category: 'undelivered', + verdict: null + }) + } + } }) }) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 13b84938174..ab330026c52 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -9,7 +9,11 @@ // provider. None is ever re-delivered under its own id — `rejected` is terminal in the reducer — so // a retry rotates the client message id, which is a new message and cannot duplicate. -import { readAgentSessionFailureFact, type AgentSessionFailureKind } from './agent-session-failure' +import { + isSubmissionRejectionKind, + readAgentSessionFailureFact, + type SubmissionRejectionKind +} from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' /** Orca could not hand the message over. Written bare: released clients hide it, and the error @@ -63,15 +67,14 @@ export type DispatchRejectionCategory = export type DispatchRejectionClassification = { category: DispatchRejectionCategory - /** `failure` when the chat reads Failed; null only when no one failed the user: a withdrawal, or - * a host restart or chat close that left the message undelivered. */ + /** `failure` when the chat reads Failed; null only when no one failed the user: a withdrawal, a + * host restart, a chat close, or a send the provider never received after a restart. */ verdict: 'failure' | null /** The situation, when the row carried one or a legacy marker names it; absent for a legacy - * sentence, whose words are all a reader has. */ - kind?: AgentSessionFailureKind + * sentence, whose words are all a reader has, and for a fact this build cannot place. */ + kind?: SubmissionRejectionKind } -/** Status-row kinds never reach a submission; they are classified only so the table is total. */ const KIND_CATEGORY = { cancelled: 'withdrawn', hostRestarted: 'undelivered', @@ -92,22 +95,35 @@ const KIND_CATEGORY = { emptyMessage: 'content', queueFull: 'transport', writeFailed: 'transport', - hostFault: 'transport', - compactionFailed: 'transport', - compactionUnconfirmed: 'transport', - cancelUnconfirmed: 'transport', - answerUnconfirmed: 'transport', - providerRetrying: 'transport' -} satisfies Record - -const NO_FAILURE_KINDS: ReadonlySet = new Set([ - 'cancelled', - 'hostRestarted', - 'chatClosed' -]) + hostFault: 'transport' +} satisfies Record + +/** Null only where no one failed the user; a new kind does not compile until it is placed here. */ +const KIND_VERDICT = { + cancelled: null, + hostRestarted: null, + chatClosed: null, + notDelivered: null, + providerExited: 'failure', + providerStartFailed: 'failure', + startFailed: 'failure', + notSignedIn: 'failure', + historyTooLarge: 'failure', + managedAccountEnvOverride: 'failure', + accountSwitchInProgress: 'failure', + managedAccountUnsupported: 'failure', + restartFailed: 'failure', + providerRejected: 'failure', + attachmentInvalid: 'failure', + attachmentUnreadable: 'failure', + emptyMessage: 'failure', + queueFull: 'failure', + writeFailed: 'failure', + hostFault: 'failure' +} satisfies Record /** The legacy markers, by the kind each stands for. */ -const LEGACY_MARKER_KINDS: ReadonlyMap = new Map([ +const LEGACY_MARKER_KINDS: ReadonlyMap = new Map([ [DISPATCH_REJECTED_CANCELLED, 'cancelled'], [DISPATCH_REJECTED_HOST_RESTARTED, 'hostRestarted'], [DISPATCH_REJECTED_PROVIDER_CLOSED, 'chatClosed'], @@ -117,7 +133,7 @@ const LEGACY_MARKER_KINDS: ReadonlyMap = new Ma ]) /** The kind a legacy marker stands for; undefined for any other reason, which is a sentence. */ -function legacyMarkerKind(reason: string | null): AgentSessionFailureKind | undefined { +function legacyMarkerKind(reason: string | null): SubmissionRejectionKind | undefined { if (dispatchRejectionWasTransportWriteFailure(reason)) { return 'writeFailed' } @@ -126,23 +142,24 @@ function legacyMarkerKind(reason: string | null): AgentSessionFailureKind | unde /** * The one reader of why a submission was rejected. The typed fact decides when the row carries - * one this build can place; a row from an older host is read by its legacy marker, and any other - * reason is a sentence — a provider's, or Orca's before rows were typed — which reads as a - * content failure. + * one; a fact this build cannot place says only that the message was not sent. A row with no + * fact, from an older host, is read by its legacy marker, and any other reason is a sentence — a + * provider's, or Orca's before rows were typed — which reads as a content failure. */ export function classifyDispatchRejection( - submission: Pick + submission: Pick & { rejection?: unknown } ): DispatchRejectionClassification { - const kind = - readAgentSessionFailureFact(submission.rejection)?.kind ?? legacyMarkerKind(submission.reason) + const { rejection } = submission + const factKind = readAgentSessionFailureFact(rejection)?.kind + if (typeof rejection === 'object' && rejection !== null && !isSubmissionRejectionKind(factKind)) { + // Undelivered, not content: all a fact this build can't place proves is the message didn't happen. + return { category: 'undelivered', verdict: null } + } + const kind = isSubmissionRejectionKind(factKind) ? factKind : legacyMarkerKind(submission.reason) if (!kind) { return { category: 'content', verdict: 'failure' } } - return { - category: KIND_CATEGORY[kind], - verdict: NO_FAILURE_KINDS.has(kind) ? null : 'failure', - kind - } + return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } /** A submission that says Orca never handed it over, in any dispatch state: journals written From c7b4330a0df7240c1d94e97aa3ea3302782b21df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:11:22 -0700 Subject: [PATCH 104/122] fix(native-chat): a send refused after its agent exited keeps its place in the queue When Orca cannot tell who owns a chat but the refusal says the agent process has exited, the next attempt may use a new id, since nothing can run the old one. It no longer marks the message as rejected: nothing recorded it, so it still holds the head of the queue, and later messages wait behind it instead of being sent ahead of it. --- .../agent-session-refusal-retry.test.ts | 36 ++++++++++++++++++- src/shared/structured-agent-session-outbox.ts | 19 +++++----- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts index 4a436aaf4e6..30a9a177f8e 100644 --- a/src/shared/agent-session-refusal-retry.test.ts +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -3,9 +3,11 @@ import type { AgentSessionOwnerVerdict } from './agent-session-wire' import { agentSessionOwnerVerdictAllowsFreshOperationId } from './agent-session-refusal-retry' import { parseAgentSessionWriteFailure } from './agent-session-write-failure' import { + admitStructuredAgentSessionOutboxEntry, createStructuredAgentSessionOutboxEntry, requeueStructuredAgentSessionSendRefusal } from './structured-agent-session-outbox' +import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' const entry = createStructuredAgentSessionOutboxEntry({ clientMessageId: 'message-1', @@ -43,7 +45,39 @@ function retried(ownerVerdict: AgentSessionOwnerVerdict | undefined) { describe("the owner's verdict decides a retry's operation id only as a floor", () => { it('lets a retry use a new id once the stored verdict proves nothing runs', () => { expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited')).toBe(true) - expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2', state: 'rejected' }) + // A new id only: nothing recorded the message, so it stays queued at the head. + expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2', state: 'queued' }) + }) + + it('keeps later sends behind the message after an exited refusal', () => { + const later = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'message-3', + sessionId: 'session-1', + text: 'after', + attachments: [], + queuedAt: 2 + }) + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry, later], + entry, + blockedClientMessageId: null, + result: { + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'x', + details: { reason: 'ownerUnproven', ownerVerdict: 'exited' } + } + }, + createOperationId: () => 'message-2' + }) + expect(disposition.blockedClientMessageId).toBe('message-2') + expect( + admitStructuredAgentSessionOutboxEntry( + disposition.entries, + disposition.blockedClientMessageId + ) + ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'message-2' } }) }) it.each(['unverifiable', 'live', undefined])( diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index bbf9cdfc0b3..4211bbb3961 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -156,24 +156,27 @@ export function requeueStructuredAgentSessionSendRefusal( createOperationId: () => string, retainOperationId = false ): StructuredAgentSessionOutboxEntry { - const refusalSettled = - agentSessionRefusalOperationState(refusal.code) === 'settled-rejected' || - (refusal.code === 'agent_session_ownership_unknown' && - agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict)) + const refusalSettled = agentSessionRefusalOperationState(refusal.code) === 'settled-rejected' + // An exited owner runs nothing under the old id, so a new one can't collide; the message still + // holds the head, since nothing recorded it. + const ownerExited = + refusal.code === 'agent_session_ownership_unknown' && + agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) if ( - !refusalSettled || + !(refusalSettled || ownerExited) || retainOperationId || entry.state === 'unconfirmed' || entry.retryAfterUnknownSubmittedAt !== null ) { 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. + // Only here may the id rotate: an earlier attempt under this id, or one whose delivery was in + // doubt, may have landed, so those stay queued behind the block. Only a settled refusal proves + // the message never landed and so releases the queue. return { ...entry, clientMessageId: createOperationId(), - state: 'rejected', + state: refusalSettled ? 'rejected' : 'queued', lastAttemptAt: null, retryAfterUnknownSubmittedAt: null } From 11b23cc32c46d68fe897201dbdf9a8c231a5541f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:18:13 -0700 Subject: [PATCH 105/122] fix(native-chat): stop reading a provider's words from an error that contains itself A cleanup that aggregates errors restarted the depth count for each one, so an aggregate error that contains itself recursed until the host ran out of stack. One depth bound now covers both the cause chain and the aggregated errors. --- src/shared/agent-session-failure.test.ts | 6 ++++++ src/shared/agent-session-failure.ts | 15 +++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index a15467b6a31..4dc12f9bfe8 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -28,6 +28,12 @@ describe('provider diagnostics', () => { ) }) + it('ends on an aggregate error that contains itself', () => { + const loop = new AggregateError([], 'loop') + loop.errors.push(loop) + expect(providerDiagnosticOf(loop)).toBeUndefined() + }) + it('is never inferred from an error that did not carry one', () => { // Orca's own wording, even when it quotes something that looks like a provider message. expect( diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 8f280456c84..c5674747b2f 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -228,15 +228,22 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure /** The provider-authored diagnostic an error carries, set only where it was composed. Follows the * `cause` chain, since wrappers such as the acquisition errors keep the original as their cause. */ export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined { + return providerDiagnosticWithin(error, 0) +} + +// One depth bound across `cause` and aggregated errors, so an error that contains itself ends. +function providerDiagnosticWithin(error: unknown, start: number): ProviderDiagnostic | undefined { let current: unknown = error - for (let depth = 0; depth < 6 && current instanceof Error; depth += 1) { + for (let depth = start; depth < 6 && current instanceof Error; depth += 1) { if ('providerDiagnostic' in current && isProviderDiagnostic(current.providerDiagnostic)) { return current.providerDiagnostic } if (current instanceof AggregateError) { - const found = current.errors.map(providerDiagnosticOf).find(Boolean) - if (found) { - return found + for (const inner of current.errors) { + const found = providerDiagnosticWithin(inner, depth + 1) + if (found) { + return found + } } } current = current.cause From 2e89498ad46a6a7519c5e2c91b4c664e2dfe3a04 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:11:14 -0700 Subject: [PATCH 106/122] fix(native-chat): say a chat whose history can't be read can't continue, and to start a new one A read of a chat's history is refused with `agent_session_journal_unreadable` only when the chat's journal file is corrupt or not a database, which no retry can change. The notice table had no words for a read at all, so a pane had nothing to show but the raw code. Reading a chat's history is now its own request kind, `read-history`, and that refusal on it reads "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue.", whether the host names the reason or raises the bare code. A write refused under the same code keeps its words, because its cause is any failed open, which can clear. Any other read refusal says only "This chat's history couldn't be loaded." `agentSessionReadHistoryRefusalParts(code, details)` gives a pane those words from a read error. The notice sentences move to their own module so the table stays within its size limit. --- .../agent-session-write-notice-text.test.ts | 4 +- .../agent-session-write-notice-text.ts | 8 +- .../src/i18n/en-runtime-required.json | 4 +- src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + .../agent-session-refusal-notice.test.ts | 71 ++++++++- src/shared/agent-session-refusal-notice.ts | 144 +++++++++--------- src/shared/agent-session-write-failure.ts | 3 +- src/shared/agent-session-write-notice-copy.ts | 62 ++++++++ ...ructured-agent-session-send-disposition.ts | 4 +- 14 files changed, 225 insertions(+), 87 deletions(-) create mode 100644 src/shared/agent-session-write-notice-copy.ts diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts index c4cbe3fc392..260c910d01e 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { i18n } from '@/i18n/i18n' import en from '@/i18n/locales/en.json' +import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY, - agentSessionWriteNoticeEnglish, type AgentSessionWriteNoticeSentence -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-notice-copy' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' const SENTENCES = Object.keys(AGENT_SESSION_WRITE_NOTICE_COPY).filter( diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index b5482c48d23..227f2b7260d 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -2,18 +2,20 @@ // shared English as its fallback so desktop and mobile never say it differently. import { translate } from '@/i18n/i18n' +import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, - agentSessionWriteNoticeParts, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure, AgentSessionWriteKind } from '../../../../shared/agent-session-write-failure' const SENTENCES: Record string> = { + notDoneReadHistory: () => + translate('components.native-chat.writeNotice.notDoneReadHistory', COPY.notDoneReadHistory), notDoneSend: () => translate('components.native-chat.writeNotice.notDoneSend', COPY.notDoneSend), tryAgainComposerSend: () => translate('components.native-chat.writeNotice.tryAgainComposerSend', COPY.tryAgainComposerSend), @@ -38,6 +40,8 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.questionChanged', COPY.questionChanged), historyUnreadable: () => translate('components.native-chat.writeNotice.historyUnreadable', COPY.historyUnreadable), + historyUnusable: () => + translate('components.native-chat.writeNotice.historyUnusable', COPY.historyUnusable), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b63e9c38661..f50678a0d4e 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2716,22 +2716,24 @@ "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "The Orca running this chat has no record of it.", + "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", "conversationCleared": "This conversation has been cleared.", "goalsUnsupported": "This agent doesn't support goals.", "historyUnreadable": "Orca couldn't read this chat's saved history.", + "historyUnusable": "This chat's history couldn't be read, so it can't continue here.", "hostReconciling": "Orca is still checking on this chat after restarting.", "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", "notDoneAnswer": "Your answer was not sent.", "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", "notDoneOption": "The setting wasn't changed.", + "notDoneReadHistory": "This chat's history couldn't be loaded.", "notDoneSend": "Your message was not sent.", "notDoneStop": "The agent wasn't stopped.", "notDoneStopTask": "The background task wasn't stopped.", "notDoneStopTasks": "The background tasks weren't stopped.", "openCurrentConversation": "Open the current conversation to continue.", - "clearUnfinished": "The last /clear didn't finish.", "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5d7b93c490f..c54fb9e746a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17466,6 +17466,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "This chat's history couldn't be loaded.", "notDoneSend": "Your message was not sent.", "tryAgainComposerSend": "Send it again.", "notDoneStop": "The agent wasn't stopped.", @@ -17480,6 +17481,7 @@ "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "questionChanged": "This question was already answered or has changed.", "historyUnreadable": "Orca couldn't read this chat's saved history.", + "historyUnusable": "This chat's history couldn't be read, so it can't continue here.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't record it in this chat's history.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cf8d9646e64..db07ff81a64 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14926,6 +14926,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "No se pudo cargar el historial de este chat.", "notDoneSend": "Su mensaje no se envió.", "tryAgainComposerSend": "Vuelva a enviarlo.", "notDoneStop": "El agente no se detuvo.", @@ -14940,6 +14941,7 @@ "outcomeUnknown": "Orca no pudo confirmar qué pasó. Revise el chat.", "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", + "historyUnusable": "No se pudo leer el historial de este chat, así que no puede continuar aquí.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 547b7209e2c..14f679819a0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17409,6 +17409,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", "notDoneSend": "Votre message n'a pas été envoyé.", "tryAgainComposerSend": "Renvoyez-le.", "notDoneStop": "L'agent n'a pas été arrêté.", @@ -17423,6 +17424,7 @@ "outcomeUnknown": "Orca n'a pas pu confirmer ce qui s'est passé. Vérifiez le chat.", "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", + "historyUnusable": "L'historique de ce chat n'a pas pu être lu, il ne peut donc pas continuer ici.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5567502e9e3..97b208c8dcc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17345,6 +17345,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", "notDoneSend": "メッセージは送信されませんでした。", "tryAgainComposerSend": "もう一度送信してください。", "notDoneStop": "Agent は停止されませんでした。", @@ -17359,6 +17360,7 @@ "outcomeUnknown": "Orca は何が起きたかを確認できませんでした。チャットを確認してください。", "questionChanged": "この質問はすでに回答済みか、変更されています。", "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", + "historyUnusable": "このチャットの履歴を読み取れなかったため、ここでは続けられません。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f36ce09e8a0..0ae14eaf513 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17345,6 +17345,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", "notDoneSend": "메시지가 전송되지 않았습니다.", "tryAgainComposerSend": "다시 보내세요.", "notDoneStop": "에이전트가 중지되지 않았습니다.", @@ -17359,6 +17360,7 @@ "outcomeUnknown": "Orca가 무슨 일이 있었는지 확인하지 못했습니다. 채팅을 확인하세요.", "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", + "historyUnusable": "이 채팅의 기록을 읽지 못해 여기에서 계속할 수 없습니다.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1a2e3c63f0f..9ede0dba86a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17310,6 +17310,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "无法加载此聊天的历史记录。", "notDoneSend": "您的消息未发送。", "tryAgainComposerSend": "请重新发送。", "notDoneStop": "智能体未停止。", @@ -17324,6 +17325,7 @@ "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", "questionChanged": "此问题已被回答或已更改。", "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", + "historyUnusable": "无法读取此聊天的历史记录,因此无法在此继续。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index dfadb6f65e2..b537946f002 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -4,14 +4,17 @@ import { type AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { - AGENT_SESSION_WRITE_NOTICE_COPY, + agentSessionReadHistoryRefusalParts, agentSessionRefusalNotice, agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, agentSessionWriteNoticeEnglish, - agentSessionWriteNoticeParts, - type AgentSessionWriteNoticeSentence + agentSessionWriteNoticeParts } from './agent-session-refusal-notice' +import { + AGENT_SESSION_WRITE_NOTICE_COPY, + type AgentSessionWriteNoticeSentence +} from './agent-session-write-notice-copy' import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details' import { agentSessionFailureSentence } from './agent-session-failure-words' import { @@ -29,6 +32,7 @@ import { import { structuredAgentSessionRejectionParts } from './structured-agent-session-send-disposition' const WRITES: AgentSessionWriteKind[] = [ + 'read-history', 'send', 'composer-send', 'stop', @@ -75,6 +79,7 @@ const FAILURES: AgentSessionWriteFailure[] = [ FUTURE_CODE ] const NOT_DONE: Record = { + 'read-history': 'notDoneReadHistory', send: 'notDoneSend', 'composer-send': 'notDoneSend', stop: 'notDoneStop', @@ -103,6 +108,7 @@ function causeSaysNotDone( const code = codeOf(failure) return ( code === 'structured_agent_session_unsupported' || + (write === 'read-history' && code === 'agent_session_journal_unreadable') || (write === 'answer' && (code === 'agent_session_item_revision_stale' || code === 'agent_session_already_resolved')) ) @@ -163,7 +169,11 @@ describe('the notice for every failure and write', () => { }) it('names a cause only for a code on the allowlist', () => { - for (const { failure, parts, cell } of cells) { + for (const { failure, write, parts, cell } of cells) { + if (write === 'read-history' && codeOf(failure) === 'agent_session_journal_unreadable') { + expect(parts, cell).toEqual(['historyUnusable', 'startNewChat']) + continue + } const cause = failure.kind === 'unconfirmed' ? 'outcomeUnknown' @@ -318,7 +328,7 @@ describe('the notice for every reason a host names', () => { failure, write, parts: agentSessionWriteNoticeParts(failure, write), - words: agentSessionRefusalReasonWords(failure), + words: agentSessionRefusalReasonWords(failure, write), cell: `${reasonOf(failure)} x ${write}` })) ) @@ -363,7 +373,10 @@ describe('the notice for every reason a host names', () => { const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) const answeredAway = write === 'answer' && parts.includes('questionChanged') const unsupported = failure.code === 'structured_agent_session_unsupported' - expect(notDone, cell).toEqual(answeredAway || unsupported ? [] : [NOT_DONE[write]]) + const saysNotDone = parts.includes('historyUnusable') + expect(notDone, cell).toEqual( + answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]] + ) } }) @@ -448,3 +461,49 @@ it('keeps a sentence the failure rows share word for word', () => { AGENT_SESSION_WRITE_NOTICE_COPY.startNewChat ) }) + +// A read of a chat's history is refused this way only for a journal file no retry can read. +describe('a read of a chat history whose journal cannot be read', () => { + const FINAL = + "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue." + + it.each([ + ['a host that names the reason', { reason: 'journalUnreadable' }], + ['a read that raises the bare code', undefined] + ] as const)( + 'says the chat cannot continue here and to start a new one: %s', + (_label, details) => { + const parts = agentSessionReadHistoryRefusalParts('agent_session_journal_unreadable', details) + expect(parts).toEqual(['historyUnusable', 'startNewChat']) + expect(agentSessionWriteNoticeEnglish(parts)).toBe(FINAL) + expect( + agentSessionRefusalNotice( + { code: 'agent_session_journal_unreadable', message: HOST_TEXT, details }, + 'read-history' + ) + ).toBe(FINAL) + } + ) + + it('keeps the words a write refused the same way has, whose cause can clear', () => { + expect( + agentSessionRefusalNotice( + { + code: 'agent_session_journal_unreadable', + message: HOST_TEXT, + details: { reason: 'journalUnreadable' } + }, + 'send' + ) + ).toBe("Orca couldn't read this chat's saved history. Your message was not sent.") + }) + + it('says only that the history did not load for any other read refusal', () => { + expect(agentSessionReadHistoryRefusalParts('agent_session_ownership_unknown')).toEqual([ + 'notDoneReadHistory' + ]) + expect(agentSessionReadHistoryRefusalParts('agent_session_from_the_future')).toEqual([ + 'notDoneReadHistory' + ]) + }) +}) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 4bcfac742c7..98bc289b210 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -10,78 +10,27 @@ // nothing saved carries copy. import type { AgentSessionFailureKind } from './agent-session-failure' -import { - agentSessionFailureSentence, - QUIT_TERMINAL_AGENT, - START_NEW_CHAT, - TERMINAL_AGENT_HOLDS_CHAT -} from './agent-session-failure-words' +import { agentSessionFailureSentence } from './agent-session-failure-words' import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import { + AGENT_SESSION_WRITE_NOTICE_COPY, + type AgentSessionWriteNoticePart, + type AgentSessionWriteNoticeSentence +} from './agent-session-write-notice-copy' import type { AgentSessionWireRefusal, AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { agentSessionRefusalFailure, + parseAgentSessionWriteFailure, type AgentSessionWriteFailure, type AgentSessionWriteKind, type AgentSessionWriteRefusal } from './agent-session-write-failure' -/** Every sentence a notice is made of. Desktop translates each whole sentence with this as its - * fallback; mobile shows it as is. */ -export const AGENT_SESSION_WRITE_NOTICE_COPY = { - notDoneSend: 'Your message was not sent.', - tryAgainComposerSend: 'Send it again.', - notDoneStop: "The agent wasn't stopped.", - notDoneStopTask: "The background task wasn't stopped.", - notDoneStopTasks: "The background tasks weren't stopped.", - notDoneAnswer: 'Your answer was not sent.', - notDoneOption: "The setting wasn't changed.", - notDoneCommand: "The command didn't run.", - notDoneGoal: "The goal wasn't changed.", - restartFailed: "The agent couldn't restart.", - capacity: 'Orca has received too many requests in the last day.', - outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", - questionChanged: 'This question was already answered or has changed.', - historyUnreadable: "Orca couldn't read this chat's saved history.", - unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", - unreachable: "Orca couldn't reach the agent.", - recordFailed: "Orca couldn't record it in this chat's history.", - conversationCleared: 'This conversation has been cleared.', - openCurrentConversation: 'Open the current conversation to continue.', - clearUnfinished: "The last /clear didn't finish.", - commandRunning: 'A /compact or /clear is still running.', - waitForCommand: 'Wait for the /compact or /clear to finish.', - agentStarting: 'The agent is still starting.', - waitForStart: 'Wait for the agent to finish starting.', - turnActive: 'The agent is still responding.', - waitForTurn: 'Wait for the agent to finish responding, or stop it.', - promptPending: 'The agent is waiting for an answer to a question or approval.', - answerFirst: 'Answer the question or approval first.', - backgroundTasksRunning: 'Background tasks are still running.', - waitForBackgroundTasks: 'Wait for the background tasks to finish.', - messagesUnsettled: "A message you sent earlier hasn't been confirmed yet.", - settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', - optionRejected: "The agent didn't accept this setting.", - goalsUnsupported: "This agent doesn't support goals.", - agentRefused: 'The agent turned this down.', - ownerUnproven: "Orca hasn't confirmed that this chat's previous agent stopped.", - reopenChat: 'Reopen the chat to check again.', - terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, - quitTerminalAgent: QUIT_TERMINAL_AGENT, - hostReconciling: 'Orca is still checking on this chat after restarting.', - waitMoment: 'Wait a moment.', - recordUnreadable: "Orca couldn't read this chat's saved state.", - chatNotFound: 'The Orca running this chat has no record of it.', - startNewChat: START_NEW_CHAT -} as const - -export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY -/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ -export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } - const NOT_DONE: Record = { + 'read-history': 'notDoneReadHistory', send: 'notDoneSend', 'composer-send': 'notDoneSend', stop: 'notDoneStop', @@ -131,6 +80,8 @@ export type AgentSessionRefusalReasonWords = /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } +type AgentSessionRefusalCauseWords = Extract + function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { return { words: 'code', action } } @@ -143,6 +94,18 @@ function causeWords( return step ? { cause, step, action } : { cause, action } } +/** A reason whose honest words differ when it refuses a read of the chat's history. */ +type AgentSessionRefusalReasonRow = + | AgentSessionRefusalReasonWords + | { readHistory: AgentSessionRefusalReasonWords; write: AgentSessionRefusalReasonWords } + +// A read raises it only for a journal file no retry can read, so only a new chat continues. +const HISTORY_UNUSABLE: AgentSessionRefusalCauseWords = { + cause: 'historyUnusable', + step: 'startNewChat', + action: 'goElsewhere' +} + const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. @@ -243,7 +206,10 @@ const REASON_WORDS = { }, agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, - agent_session_journal_unreadable: { journalUnreadable: codeWords('hostFinding') }, + agent_session_journal_unreadable: { + // A write's covers any open that failed, which can clear. + journalUnreadable: { readHistory: HISTORY_UNUSABLE, write: codeWords('hostFinding') } + }, // Thrown, so a client meets these only as an RPC error; the code's words stand. structured_agent_session_unsupported: { clientCapabilityMissing: codeWords('updateOrca'), @@ -254,22 +220,41 @@ const REASON_WORDS = { } satisfies { [C in AgentSessionWireRefusalCode]: Record< AgentSessionRefusalReason, - AgentSessionRefusalReasonWords + AgentSessionRefusalReasonRow > } -/** The words and next step a reason gets; the code's row when the refusal names none. */ +/** The words and next step a reason gets on this request; undefined when the refusal names none. */ export function agentSessionRefusalReasonWords( - failure: AgentSessionWriteRefusal + failure: AgentSessionWriteRefusal, + request: AgentSessionWriteKind ): AgentSessionRefusalReasonWords | undefined { const reason = failure.details?.reason - const byReason: Partial> | undefined = + const byReason: Partial> | undefined = REASON_WORDS[failure.code] - return reason === undefined ? undefined : byReason?.[reason] + const row = reason === undefined ? undefined : byReason?.[reason] + if (!row || !('readHistory' in row)) { + return row + } + return request === 'read-history' ? row.readHistory : row.write } -function questionChangedParts(write: AgentSessionWriteKind): AgentSessionWriteNoticeSentence[] { - return write === 'answer' ? ['questionChanged'] : ['questionChanged', NOT_DONE[write]] +/** A cause, and that the request did not happen unless the cause already says so. */ +function causeParts( + cause: AgentSessionWriteNoticeSentence, + write: AgentSessionWriteKind +): AgentSessionWriteNoticeSentence[] { + const saysNotDone = + cause === 'historyUnusable' || (cause === 'questionChanged' && write === 'answer') + return saysNotDone ? [cause] : [cause, NOT_DONE[write]] +} + +function causeWordsParts( + words: AgentSessionRefusalCauseWords, + write: AgentSessionWriteKind +): AgentSessionWriteNoticeSentence[] { + const said = causeParts(words.cause, write) + return words.step ? [...said, words.step] : said } /** The notice a named reason has of its own; undefined leaves the code's words. */ @@ -277,7 +262,7 @@ function reasonParts( failure: AgentSessionWriteRefusal, write: AgentSessionWriteKind ): AgentSessionWriteNoticePart[] | undefined { - const words = agentSessionRefusalReasonWords(failure) + const words = agentSessionRefusalReasonWords(failure, write) if (!words || 'words' in words) { return undefined } @@ -286,9 +271,7 @@ function reasonParts( ? [NOT_DONE[write], { text: agentSessionFailureSentence({ kind: words.fact }, 'rejection') }] : undefined } - const said = - words.cause === 'questionChanged' ? questionChangedParts(write) : [words.cause, NOT_DONE[write]] - return words.step ? [...said, words.step] : said + return causeWordsParts(words, write) } export function agentSessionWriteNoticeParts( @@ -335,9 +318,12 @@ export function agentSessionWriteNoticeParts( // A Stop names the prompt it was pressed under, so it can be refused this way too. case 'agent_session_item_revision_stale': case 'agent_session_already_resolved': - return questionChangedParts(write) + return causeParts('questionChanged', write) + // A read raises it with no reason, and only for a file no retry can read. case 'agent_session_journal_unreadable': - return ['historyUnreadable', notDone] + return write === 'read-history' + ? causeWordsParts(HISTORY_UNUSABLE, write) + : ['historyUnreadable', notDone] case 'structured_agent_session_unsupported': return ['unsupported'] } @@ -368,3 +354,15 @@ export function agentSessionRefusalNotice( export function agentSessionWriteFailureNotice(write: AgentSessionWriteKind): string { return agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts({ kind: 'failed' }, write)) } + +/** The notice for a refused read of a chat's history, from the refusal's code and any details a + * host sent; a code this build does not know says only that the history did not load. */ +export function agentSessionReadHistoryRefusalParts( + code: string, + details?: unknown +): AgentSessionWriteNoticePart[] { + const failure = parseAgentSessionWriteFailure({ kind: 'refused', code, details }) + return failure + ? agentSessionWriteNoticeParts(failure, 'read-history') + : agentSessionWriteNotDoneParts('read-history') +} diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index cd99c6ec0ec..dd5c8f26caa 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -16,8 +16,9 @@ import { /** What the person was doing, which decides what the notice says did not happen. `send` keeps * the message behind a Retry control; `composer-send` puts it back in the composer, as the phone - * does. */ + * does. `read-history` is opening the chat to show its history, which writes nothing. */ export type AgentSessionWriteKind = + | 'read-history' | 'send' | 'composer-send' | 'stop' diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts new file mode 100644 index 00000000000..73373b4bb3d --- /dev/null +++ b/src/shared/agent-session-write-notice-copy.ts @@ -0,0 +1,62 @@ +// The sentences a chat notice is made of, each whole so desktop can translate it on its own. + +import { + QUIT_TERMINAL_AGENT, + START_NEW_CHAT, + TERMINAL_AGENT_HOLDS_CHAT +} from './agent-session-failure-words' + +/** Every sentence a notice is made of. Desktop translates each whole sentence with this as its + * fallback; mobile shows it as is. */ +export const AGENT_SESSION_WRITE_NOTICE_COPY = { + notDoneReadHistory: "This chat's history couldn't be loaded.", + notDoneSend: 'Your message was not sent.', + tryAgainComposerSend: 'Send it again.', + notDoneStop: "The agent wasn't stopped.", + notDoneStopTask: "The background task wasn't stopped.", + notDoneStopTasks: "The background tasks weren't stopped.", + notDoneAnswer: 'Your answer was not sent.', + notDoneOption: "The setting wasn't changed.", + notDoneCommand: "The command didn't run.", + notDoneGoal: "The goal wasn't changed.", + restartFailed: "The agent couldn't restart.", + capacity: 'Orca has received too many requests in the last day.', + outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", + questionChanged: 'This question was already answered or has changed.', + historyUnreadable: "Orca couldn't read this chat's saved history.", + historyUnusable: "This chat's history couldn't be read, so it can't continue here.", + unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", + unreachable: "Orca couldn't reach the agent.", + recordFailed: "Orca couldn't record it in this chat's history.", + conversationCleared: 'This conversation has been cleared.', + openCurrentConversation: 'Open the current conversation to continue.', + clearUnfinished: "The last /clear didn't finish.", + commandRunning: 'A /compact or /clear is still running.', + waitForCommand: 'Wait for the /compact or /clear to finish.', + agentStarting: 'The agent is still starting.', + waitForStart: 'Wait for the agent to finish starting.', + turnActive: 'The agent is still responding.', + waitForTurn: 'Wait for the agent to finish responding, or stop it.', + promptPending: 'The agent is waiting for an answer to a question or approval.', + answerFirst: 'Answer the question or approval first.', + backgroundTasksRunning: 'Background tasks are still running.', + waitForBackgroundTasks: 'Wait for the background tasks to finish.', + messagesUnsettled: "A message you sent earlier hasn't been confirmed yet.", + settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', + optionRejected: "The agent didn't accept this setting.", + goalsUnsupported: "This agent doesn't support goals.", + agentRefused: 'The agent turned this down.', + ownerUnproven: "Orca hasn't confirmed that this chat's previous agent stopped.", + reopenChat: 'Reopen the chat to check again.', + terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, + quitTerminalAgent: QUIT_TERMINAL_AGENT, + hostReconciling: 'Orca is still checking on this chat after restarting.', + waitMoment: 'Wait a moment.', + recordUnreadable: "Orca couldn't read this chat's saved state.", + chatNotFound: 'The Orca running this chat has no record of it.', + startNewChat: START_NEW_CHAT +} as const + +export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY +/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ +export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 9d55f8b85b8..7ffce942b6e 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -12,9 +12,9 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent import { agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts, - agentSessionWriteNotDoneParts, - type AgentSessionWriteNoticePart + agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' import { agentSessionRefusalFailure } from './agent-session-write-failure' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { From e839453e4abb64afc4b340eb0eb18312c90467d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:11:28 -0700 Subject: [PATCH 107/122] fix(native-chat): decide what every way a child ends means for queued messages in one table What a child's end means for the messages queued behind it was an if-chain: a user's Stop was checked in one place, a host stop in another, and every other cause, including one added later, fell through to "the provider exited". It is now one table over every end cause, so a new cause does not compile until someone says whether it fails what is queued and how. A user's Stop still fails nothing, a host stop is still Orca's fault, and an exit, a failed attach or an eviction still carry the failure the end recorded. Nothing a person sees changes. --- ...ctured-agent-session-delivery-loop.test.ts | 54 +++++++++++++++++++ .../structured-agent-session-delivery-loop.ts | 45 +++++++++++----- 2 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts new file mode 100644 index 00000000000..d2781398402 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-delivery-loop' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedChild +} from './structured-agent-session-host-types' + +const EXIT_FAILURE = agentSessionFailureFact('providerExited', { + detail: { text: 'crashed', audience: 'log' } +}) + +function ended( + cause: StructuredAgentSessionChildEndCause, + duringStartup: boolean +): StructuredAgentSessionEndedChild { + return { + generation: 'generation-1', + fence: 2, + rootGone: true, + cause, + reason: null, + failure: EXIT_FAILURE, + duringStartup, + endedAt: { epoch: 'epoch-1', sequence: 3 } + } +} + +describe('what a child end means for the messages queued behind it', () => { + it.each([false, true])("fails nothing after a user's Stop (during startup: %s)", (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('user-stop', starting))).toBeNull() + }) + + it.each([false, true])("is Orca's fault after a host stop (during startup: %s)", (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ + hostFault: true + }) + }) + + it.each(['exit', 'attach-failed', 'evict'] as const)( + 'carries the recorded failure of a %s, as a failed start while starting', + (cause) => { + expect(structuredAgentSessionEndedChildFailure(ended(cause, true))).toEqual({ + exit: EXIT_FAILURE + }) + expect(structuredAgentSessionEndedChildFailure(ended(cause, false))).toEqual({ + failure: EXIT_FAILURE + }) + expect( + structuredAgentSessionEndedChildFailure({ ...ended(cause, false), failure: undefined }) + ).toEqual({ failure: agentSessionFailureFact('providerExited') }) + } + ) +}) 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 7f8fba84320..0c87c640127 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,7 @@ import { } from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' import type { + StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -179,17 +180,17 @@ 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 + const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // 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') { + // queued. + if (endedFailure === null) { return 'continue' } return this.fail(sessionId, { startKey: awaited?.generation ?? null, - cause: ended - ? endedChildFailure(ended) - : // Gone with no end observed: nothing says the provider stopped. - { failure: startFailure ?? agentSessionFailureFact('startFailed') } + cause: endedFailure ?? + // Gone with no end observed: nothing says the provider stopped. + { failure: startFailure ?? agentSessionFailureFact('startFailed') } }) } const next = oldestQueuedSubmission(session) @@ -249,19 +250,37 @@ function startThatFailedWhileQueued( ) { return null } - return { startKey: ended.generation, cause: endedChildFailure(ended) } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null } -/** Why a queued message the child never took is rejected. The host stopping the child is Orca's - * cause, never the provider's. */ -function endedChildFailure( +function providerEndFailure( ended: StructuredAgentSessionEndedChild ): StructuredAgentSessionStartFailureCause { - if (ended.cause === 'host-stop') { - return { hostFault: true } - } if (ended.duringStartup) { return { exit: ended.failure } } return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } } + +// Every end cause, so a new one does not compile until it says whether it fails what is queued. +const ENDED_CHILD_FAILURE = { + 'user-stop': () => null, + // The host stopping the child is Orca's cause, never the provider's. + 'host-stop': () => ({ hostFault: true }), + exit: providerEndFailure, + // The attach records its own fault as the end's failure. + 'attach-failed': providerEndFailure, + // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. + evict: providerEndFailure +} satisfies Record< + StructuredAgentSessionChildEndCause, + (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null +> + +/** Why a queued message the child never took is rejected; null when its end fails nothing. */ +export function structuredAgentSessionEndedChildFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause | null { + return ENDED_CHILD_FAILURE[ended.cause](ended) +} From 57f1d540fffe294500d977357eea0a81b1b36290 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:27:06 -0700 Subject: [PATCH 108/122] test(native-chat): a close that stops the child and then fails rejects what is queued by how the child ended When a chat closes, stops its agent, and then fails a later step, the chat stays open with its messages still queued. The delivery loop then rejects them by the way the child ended: an eviction during startup reads as a failed start, otherwise as the provider having stopped, and either counts as a failure. The cases are rows over the end cause, so another way a chat closes is one more row. --- ...-agent-session-accept-then-deliver.test.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) 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 e85d9dd0b11..14ad5311c68 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 @@ -10,7 +10,10 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite 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 } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED +} from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -22,6 +25,7 @@ import { import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionChildEndCause } from './structured-agent-session-host-types' import { persistRewindRecord } from './structured-rewind-recovery' import { HOST_TEST_NOW as NOW, @@ -664,6 +668,50 @@ describe('an eviction between acceptance and handover', () => { }) }) +// A close whose stop lands but whose release fails keeps the conversation, so the delivery loop +// rejects what is queued by how the child ended. +describe('a close that stops the child and then fails', () => { + const END_CHILD = { + evict: () => host.close(SESSION) + } satisfies Partial Promise>> + + it.each([ + { end: 'evict', starting: true, kind: 'providerStartFailed', verdict: 'failure' }, + { end: 'evict', starting: false, kind: 'providerExited', verdict: 'failure' } + ] as const)( + 'rejects what is queued as $kind after a $end (during startup: $starting)', + async ({ end, starting, kind, verdict }) => { + const started = deferred() + adapterExtras = { + awaitStarted: () => started.promise, + // Once: the host's own teardown acknowledges again. + acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { + throw new Error('release acknowledgement failed') + }) + } + await host.close(SESSION) + await startHost() + acquire.mockImplementationOnce(async (input) => ({ + ...(await spawnChild(input)), + ...(starting ? { providerChildPhase: 'starting' as const } : {}) + })) + await host.hold(SESSION, 'surface-1') + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + + await expect(END_CHILD[end]()).rejects.toThrow() + expect(host.hasSession(SESSION)).toBe(true) + started.resolve() + + await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + const rejected = submission(id)! + expect(rejected.rejection).toMatchObject({ kind }) + expect(classifyDispatchRejection(rejected).verdict).toBe(verdict) + expect(dispatch).not.toHaveBeenCalled() + } + ) +}) + describe('a compaction or rewind an earlier child left prepared', () => { async function leftPrepared(prepare: (fence: number) => Promise): Promise { await host.close(SESSION) From 6c1d624a3efb6170c7e9fc8cecdd6a856d8592b3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:35:17 -0700 Subject: [PATCH 109/122] test(native-chat): type the refusal a persisted-schema test admits --- src/shared/agent-session-journal-schemas.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index d638f5839d6..594d6a04b11 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -357,7 +357,7 @@ describe('typed failure facts', () => { }) it("admits a refusal's details, and a row an earlier build wrote with its cause", () => { - const refused = (refusal: object) => + const refused = (refusal: Record) => isAdmissibleAgentJournalItemBody({ kind: 'status', text: "Codex couldn't restart.", From f4320d4e9275fccdc7c62a733b57b2021049ffb7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:50:17 -0700 Subject: [PATCH 110/122] fix(native-chat): say whether a chat's history is damaged or just couldn't open A write refused because the chat's journal would not open named one reason, `journalUnreadable`, for every failed open, and a read of the history took that same reason as final. So the words depended on what was asked, not on what happened: a busy or permission-denied open could tell a person to start a new chat, and a damaged one could read as something that clears. The host now decides at the refusal which it was. `journalCorrupt` is set only when SQLite itself reports the journal damaged or not a database (SQLITE_CORRUPT or SQLITE_NOTADB, extended codes included, read from the driver's result code and never from message text, through any `cause` chain). Every other failed open is `journalUnavailable`. A corrupt history reads "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue.", with "Your message was not sent." before the step on a send. One that couldn't open reads "Orca couldn't open this chat's history right now. Try again.", likewise on a send. A host that names no reason gets "Orca couldn't read this chat's saved history.", which promises neither, because damage can't be proven from the code alone. The refusal's message, which released clients print for a send, is now that person sentence instead of the open error's own text; the error is logged instead. `journalUnreadable` is replaced outright: no released build wrote it, and a stored one reads as a refusal with no reason. --- .../journal-open-failure.test.ts | 106 ++++++++++++++++++ .../journal-open-failure.ts | 22 ++++ ...tructured-agent-session-send-block.test.ts | 60 +++++++++- ...ructured-agent-session-send-preparation.ts | 21 ++-- src/main/sqlite/sqlite-read-failure.ts | 19 ++++ .../agent-session-write-notice-text.ts | 5 +- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- src/shared/agent-session-refusal-details.ts | 7 +- .../agent-session-refusal-notice.test.ts | 94 +++++++++------- src/shared/agent-session-refusal-notice.ts | 54 ++++----- src/shared/agent-session-write-notice-copy.ts | 4 +- 17 files changed, 328 insertions(+), 90 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-open-failure.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-open-failure.ts diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts new file mode 100644 index 00000000000..d2e1bf3b350 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -0,0 +1,106 @@ +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 } from 'vitest' +import { openJournalDatabase } from './journal-database' +import { classifyJournalOpenFailure } from './journal-open-failure' +import { loadJournal } from './journal-open' +import { journalDatabaseFile } from './journal-paths' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-open-failure-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** What the journal's own open throws for the file as it stands. */ +function openFailure(): unknown { + try { + loadJournal(root, 'session-1') + } catch (error) { + return error + } + throw new Error('the journal opened') +} + +/** A thrown value shaped as node:sqlite shapes one, with the result code it reports. */ +function nodeSqliteError(errcode: number): Error { + return Object.assign(new Error('sqlite'), { code: 'ERR_SQLITE_ERROR', errcode }) +} + +function systemError(code: string, errno: number): Error { + return Object.assign(new Error(`${code}: open`), { code, errno }) +} + +describe('classifyJournalOpenFailure', () => { + it('calls a journal that is not a database corrupt', async () => { + await writeFile(journalDatabaseFile(root), 'not a database '.repeat(64)) + const error = openFailure() + expect(error).toMatchObject({ errcode: 26 }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it('calls a journal whose pages are damaged corrupt', async () => { + const path = journalDatabaseFile(root) + const opened = openJournalDatabase(path) + opened.db.exec('PRAGMA journal_mode = DELETE') + opened.db.close() + const bytes = await readFile(path) + // Page 1 holds the header and schema; every table's root page follows it. + bytes.fill(0xab, 4096) + await writeFile(path, bytes) + const error = openFailure() + expect(error).toMatchObject({ errcode: 11 }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it.each([ + ['SQLITE_CORRUPT_VTAB', 267], + ['SQLITE_CORRUPT_SEQUENCE', 523], + ['SQLITE_CORRUPT_INDEX', 779] + ])('reads an extended corrupt code as corrupt: %s', (_name, errcode) => { + expect(classifyJournalOpenFailure(nodeSqliteError(errcode))).toBe('journalCorrupt') + }) + + it("reads the Bun driver's corrupt code as corrupt", () => { + const error = Object.assign(new Error('file is not a database'), { + name: 'SQLiteError', + code: 'SQLITE_NOTADB', + errno: 26 + }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it('finds corruption a wrapper names as its cause', () => { + const wrapped = new Error('opening the conversation failed', { + cause: new Error('the journal would not open', { cause: nodeSqliteError(11) }) + }) + expect(classifyJournalOpenFailure(wrapped)).toBe('journalCorrupt') + }) + + it.each([ + ['a busy database', nodeSqliteError(5)], + ['a locked database', nodeSqliteError(6)], + ['a database SQLite cannot open', nodeSqliteError(14)], + ['a disk I/O error', nodeSqliteError(10)], + ['permission denied', systemError('EACCES', -13)], + ['too many open files', systemError('EMFILE', -24)], + ['a Windows error whose low byte reads as corrupt', systemError('EUNKNOWN', -4085)], + ['an error that only claims a corrupt code', Object.assign(new Error('x'), { errcode: 11 })], + ['a thrown string', 'database disk image is malformed'], + ['nothing at all', undefined] + ])('calls any other failure one that can clear: %s', (_label, error) => { + expect(classifyJournalOpenFailure(error)).toBe('journalUnavailable') + }) + + it('stops on a cause chain that loops back on itself', () => { + const first = new Error('first') + const second = new Error('second', { cause: first }) + Object.assign(first, { cause: second }) + expect(classifyJournalOpenFailure(first)).toBe('journalUnavailable') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts new file mode 100644 index 00000000000..1835f691cb7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -0,0 +1,22 @@ +// What a failed journal open means for the chat: its history is damaged, which no retry reads +// past, or the open failed in a way that can clear (a lock, permissions, too many open files). + +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' + +export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> + +// Bounds a cause chain that loops back on itself. +const MAX_CAUSE_DEPTH = 8 + +/** Damage only where the storage says so; anything unproven can clear. */ +export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { + let current = error + for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { + if (isSqliteCorruption(current)) { + return 'journalCorrupt' + } + current = current instanceof Error ? current.cause : undefined + } + return 'journalUnavailable' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index 0a1895941b7..9604eaa0162 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionConversationCommandRecord } from '../../../shared/agent-session-conversation-command' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { + openConversationForWrite, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' function withCommand(command: AgentSessionConversationCommandRecord) { return { ...agentSessionRecordFixture(), conversationCommand: command } @@ -63,3 +66,56 @@ describe('a send refused by the conversation command it follows', () => { ).toBeNull() }) }) + +describe('a write whose conversation the host could not open', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const ENVELOPE = { + sessionId: 'session-1', + clientOperationId: 'operation-1', + expectedRuntimeFence: 1, + payloadFingerprint: '' + } + + function refusedBy(error: unknown) { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + return openConversationForWrite(async () => { + throw error + }, ENVELOPE) + } + + it('says a corrupt history is final, in words and not the error', async () => { + const corrupt = Object.assign(new Error('/Users/me/journal.db: file is not a database'), { + code: 'ERR_SQLITE_ERROR', + errcode: 26 + }) + + expect(await refusedBy(corrupt)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' }, + message: + "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue." + } + }) + }) + + it('says any other failed open can clear', async () => { + const denied = Object.assign(new Error('EACCES: permission denied, open /Users/me'), { + code: 'EACCES', + errno: -13 + }) + + expect(await refusedBy(denied)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' }, + message: "Orca couldn't open this chat's history right now. Try again." + } + }) + }) +}) 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 d60ce592e3e..7a27a4ccefa 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,6 +12,11 @@ import { } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import { + classifyJournalOpenFailure, + type JournalOpenFailure +} from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -70,19 +75,21 @@ export async function openConversationForWrite( } return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } catch (error) { + console.warn('[agent-session] opening the conversation for a write failed:', error) + const reason = classifyJournalOpenFailure(error) return { ok: false, - refusal: refuse( - 'agent_session_journal_unreadable', - { reason: 'journalUnreadable' }, - `The conversation could not be opened: ${ - error instanceof Error ? error.message : String(error) - }` - ) + refusal: refuse('agent_session_journal_unreadable', { reason }, JOURNAL_OPEN_MESSAGE[reason]) } } } +// Released clients print a refusal's message for a send; it fits a Stop too. +const JOURNAL_OPEN_MESSAGE: Record = { + journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable', 'startNewChat']), + journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) +} + /** Who a failure sentence names: the chat's agent, when the record says. */ export function structuredAgentSessionFailureWordsContext( record: AgentSessionRecord | null diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts index 6cdb75f343a..6919bf9e9cb 100644 --- a/src/main/sqlite/sqlite-read-failure.ts +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -6,7 +6,9 @@ /** Primary result codes; extended codes pack the primary code in the low byte. */ const SQLITE_BUSY = 5 const SQLITE_LOCKED = 6 +const SQLITE_CORRUPT = 11 const SQLITE_CANTOPEN = 14 +const SQLITE_NOTADB = 26 // Shared with the Codex index-heal pass, which only ever sees a relayed message // string (app-server RPC drops `errcode`), so message matching is not optional. @@ -20,6 +22,23 @@ function primaryErrcode(error: unknown): number | null { return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null } +/** node:sqlite marks its errors `ERR_SQLITE_ERROR`; Bun names its class `SQLiteError`. */ +function isSqliteDriverError(error: unknown): boolean { + return ( + error instanceof Error && + (('code' in error && error.code === 'ERR_SQLITE_ERROR') || error.name === 'SQLiteError') + ) +} + +/** True only when SQLite itself reports the database damaged or not a database at all. */ +export function isSqliteCorruption(error: unknown): boolean { + if (!isSqliteDriverError(error)) { + return false + } + const errcode = primaryErrcode(error) + return errcode === SQLITE_CORRUPT || errcode === SQLITE_NOTADB +} + function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 227f2b7260d..812197d6e33 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -42,6 +42,8 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.historyUnreadable', COPY.historyUnreadable), historyUnusable: () => translate('components.native-chat.writeNotice.historyUnusable', COPY.historyUnusable), + historyUnavailable: () => + translate('components.native-chat.writeNotice.historyUnavailable', COPY.historyUnavailable), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => @@ -106,7 +108,8 @@ const SENTENCES: Record string> = { chatNotFound: () => translate('components.native-chat.writeNotice.chatNotFound', COPY.chatNotFound), startNewChat: () => - translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat) + translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat), + tryAgain: () => translate('components.native-chat.writeNotice.tryAgain', COPY.tryAgain) } export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNoticePart[]): string { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f50678a0d4e..1f2714fc6c5 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2720,6 +2720,7 @@ "commandRunning": "A /compact or /clear is still running.", "conversationCleared": "This conversation has been cleared.", "goalsUnsupported": "This agent doesn't support goals.", + "historyUnavailable": "Orca couldn't open this chat's history right now.", "historyUnreadable": "Orca couldn't read this chat's saved history.", "historyUnusable": "This chat's history couldn't be read, so it can't continue here.", "hostReconciling": "Orca is still checking on this chat after restarting.", @@ -2747,6 +2748,7 @@ "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", + "tryAgain": "Try again.", "tryAgainComposerSend": "Send it again.", "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c54fb9e746a..ef60dc5f703 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17482,6 +17482,7 @@ "questionChanged": "This question was already answered or has changed.", "historyUnreadable": "Orca couldn't read this chat's saved history.", "historyUnusable": "This chat's history couldn't be read, so it can't continue here.", + "historyUnavailable": "Orca couldn't open this chat's history right now.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't record it in this chat's history.", @@ -17511,7 +17512,8 @@ "waitMoment": "Wait a moment.", "recordUnreadable": "Orca couldn't read this chat's saved state.", "chatNotFound": "The Orca running this chat has no record of it.", - "startNewChat": "Start a new chat to continue." + "startNewChat": "Start a new chat to continue.", + "tryAgain": "Try again." }, "taskList": { "title": "Tasks", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index db07ff81a64..63407e9bd7f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14942,6 +14942,7 @@ "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", "historyUnusable": "No se pudo leer el historial de este chat, así que no puede continuar aquí.", + "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", @@ -14971,7 +14972,8 @@ "waitMoment": "Espere un momento.", "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", "chatNotFound": "La versión de Orca que ejecuta este chat no tiene ningún registro de él.", - "startNewChat": "Inicie un chat nuevo para continuar." + "startNewChat": "Inicie un chat nuevo para continuar.", + "tryAgain": "Vuelva a intentarlo." }, "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 14f679819a0..5824baa450a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17425,6 +17425,7 @@ "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", "historyUnusable": "L'historique de ce chat n'a pas pu être lu, il ne peut donc pas continuer ici.", + "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", @@ -17454,7 +17455,8 @@ "waitMoment": "Patientez un instant.", "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", "chatNotFound": "L'instance d'Orca qui exécute ce chat n'en a aucune trace.", - "startNewChat": "Démarrez un nouveau chat pour continuer." + "startNewChat": "Démarrez un nouveau chat pour continuer.", + "tryAgain": "Réessayez." }, "composer": { "imageUnsupported": "Le collage d'image n'est pas pris en charge pour cet agent.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 97b208c8dcc..620fffb8886 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17361,6 +17361,7 @@ "questionChanged": "この質問はすでに回答済みか、変更されています。", "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", "historyUnusable": "このチャットの履歴を読み取れなかったため、ここでは続けられません。", + "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", @@ -17390,7 +17391,8 @@ "waitMoment": "しばらくお待ちください。", "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", "chatNotFound": "このチャットを実行している Orca には、このチャットの記録がありません。", - "startNewChat": "続けるには、新しいチャットを開始してください。" + "startNewChat": "続けるには、新しいチャットを開始してください。", + "tryAgain": "もう一度お試しください。" }, "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0ae14eaf513..4793fbeaf5b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17361,6 +17361,7 @@ "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", "historyUnusable": "이 채팅의 기록을 읽지 못해 여기에서 계속할 수 없습니다.", + "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", @@ -17390,7 +17391,8 @@ "waitMoment": "잠시 기다리세요.", "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", "chatNotFound": "이 채팅을 실행하는 Orca에 이 채팅의 기록이 없습니다.", - "startNewChat": "계속하려면 새 채팅을 시작하세요." + "startNewChat": "계속하려면 새 채팅을 시작하세요.", + "tryAgain": "다시 시도하세요." }, "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9ede0dba86a..339d4c412d2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17326,6 +17326,7 @@ "questionChanged": "此问题已被回答或已更改。", "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", "historyUnusable": "无法读取此聊天的历史记录,因此无法在此继续。", + "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", @@ -17355,7 +17356,8 @@ "waitMoment": "请稍候。", "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", "chatNotFound": "运行此聊天的 Orca 没有此聊天的记录。", - "startNewChat": "请开始新的聊天以继续。" + "startNewChat": "请开始新的聊天以继续。", + "tryAgain": "请重试。" }, "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index dafcd93d20a..697462c2eba 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -91,7 +91,12 @@ export const AGENT_SESSION_REFUSAL_REASONS = { ], agent_session_item_revision_stale: ['promptMoved'], agent_session_already_resolved: ['promptAlreadyResolved'], - agent_session_journal_unreadable: ['journalUnreadable'], + agent_session_journal_unreadable: [ + /** SQLite reports the chat's journal damaged or not a database; no retry reads past it. */ + 'journalCorrupt', + /** Any other failed open, which can clear. */ + 'journalUnavailable' + ], structured_agent_session_unsupported: [ 'clientCapabilityMissing', 'hostDisabled', diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index b537946f002..c1534435e36 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -58,7 +58,7 @@ const CAUSES: Partial { }) it('names a cause only for a code on the allowlist', () => { - for (const { failure, write, parts, cell } of cells) { - if (write === 'read-history' && codeOf(failure) === 'agent_session_journal_unreadable') { - expect(parts, cell).toEqual(['historyUnusable', 'startNewChat']) - continue - } + for (const { failure, parts, cell } of cells) { const cause = failure.kind === 'unconfirmed' ? 'outcomeUnknown' @@ -199,7 +195,8 @@ describe('the notice for every failure and write', () => { // - item_revision_stale / already_resolved: "Item has moved on." (prompt-state) // - owner_restart_failed: " couldn't restart: .", where the cause is the resume's // own refusal message, including the ledger's (send-preparation, hold-resume) - // - journal_unreadable: no emitter on this host; an older or newer one may send it. + // - journal_unreadable: "The conversation could not be opened: " (send preparation, before + // it named a reason) it('is never empty and never shows the host message', () => { for (const { failure, write, parts, cell } of cells) { expect(parts.length, cell).toBeGreaterThan(0) @@ -328,7 +325,7 @@ describe('the notice for every reason a host names', () => { failure, write, parts: agentSessionWriteNoticeParts(failure, write), - words: agentSessionRefusalReasonWords(failure, write), + words: agentSessionRefusalReasonWords(failure), cell: `${reasonOf(failure)} x ${write}` })) ) @@ -349,12 +346,15 @@ describe('the notice for every reason a host names', () => { } }) + // A retry is the control that sent the write; only an open that can clear says to try again. it('names a step exactly where the person has one to take', () => { for (const { words, cell } of cells) { if (words && 'cause' in words) { - expect(words.step !== undefined, cell).toBe( + const retryNow = words.action === 'retry' && words.step === 'tryAgain' + expect(words.step !== undefined && !retryNow, cell).toBe( words.action === 'wait' || words.action === 'actFirst' || words.action === 'goElsewhere' ) + expect(retryNow, cell).toBe(words.cause === 'historyUnavailable') } } }) @@ -373,7 +373,8 @@ describe('the notice for every reason a host names', () => { const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) const answeredAway = write === 'answer' && parts.includes('questionChanged') const unsupported = failure.code === 'structured_agent_session_unsupported' - const saysNotDone = parts.includes('historyUnusable') + const saysNotDone = + write === 'read-history' && failure.code === 'agent_session_journal_unreadable' expect(notDone, cell).toEqual( answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]] ) @@ -462,40 +463,53 @@ it('keeps a sentence the failure rows share word for word', () => { ) }) -// A read of a chat's history is refused this way only for a journal file no retry can read. -describe('a read of a chat history whose journal cannot be read', () => { - const FINAL = - "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue." +// A failed journal open says what happened to the history, not what was asked of it. +describe('a chat whose history the host could not open', () => { + const notice = (details: unknown, write: AgentSessionWriteKind): string => + agentSessionRefusalNotice( + JSON.parse( + JSON.stringify({ code: 'agent_session_journal_unreadable', message: HOST_TEXT, details }) + ), + write + ) it.each([ - ['a host that names the reason', { reason: 'journalUnreadable' }], - ['a read that raises the bare code', undefined] - ] as const)( - 'says the chat cannot continue here and to start a new one: %s', - (_label, details) => { - const parts = agentSessionReadHistoryRefusalParts('agent_session_journal_unreadable', details) - expect(parts).toEqual(['historyUnusable', 'startNewChat']) - expect(agentSessionWriteNoticeEnglish(parts)).toBe(FINAL) - expect( - agentSessionRefusalNotice( - { code: 'agent_session_journal_unreadable', message: HOST_TEXT, details }, - 'read-history' - ) - ).toBe(FINAL) - } - ) + [ + 'journalCorrupt', + 'read-history', + "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue." + ], + [ + 'journalCorrupt', + 'send', + "This chat's history couldn't be read, so it can't continue here. Your message was not sent. Start a new chat to continue." + ], + [ + 'journalUnavailable', + 'read-history', + "Orca couldn't open this chat's history right now. Try again." + ], + [ + 'journalUnavailable', + 'send', + "Orca couldn't open this chat's history right now. Your message was not sent. Try again." + ] + ] as const)('%s on %s', (reason, write, expected) => { + expect(notice({ reason }, write)).toBe(expected) + }) - it('keeps the words a write refused the same way has, whose cause can clear', () => { + // Damage can't be told apart from an open that can clear, so it never says to start over. + it.each([ + ['an older host', undefined], + ['a reason an unreleased build wrote', { reason: 'journalUnreadable' }] + ])('promises nothing for a refusal that names no reason: %s', (_label, details) => { + expect(notice(details, 'read-history')).toBe("Orca couldn't read this chat's saved history.") + expect(notice(details, 'send')).toBe( + "Orca couldn't read this chat's saved history. Your message was not sent." + ) expect( - agentSessionRefusalNotice( - { - code: 'agent_session_journal_unreadable', - message: HOST_TEXT, - details: { reason: 'journalUnreadable' } - }, - 'send' - ) - ).toBe("Orca couldn't read this chat's saved history. Your message was not sent.") + agentSessionReadHistoryRefusalParts('agent_session_journal_unreadable', details) + ).toEqual(['historyUnreadable']) }) it('says only that the history did not load for any other read refusal', () => { diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 98bc289b210..fd15da09931 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -6,8 +6,8 @@ // own row names a cause only where every emitter of the code means it; it is also the words for a // host too old to send a reason. A notice says how to get past a refusal only where the person has // a step to take; retrying is the control that sent the write, except on the phone, whose message -// goes back to the composer. Surfaces keep the fact and choose the words when they show it, so -// nothing saved carries copy. +// goes back to the composer, and a history that couldn't open right now says to try again. +// Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. import type { AgentSessionFailureKind } from './agent-session-failure' import { agentSessionFailureSentence } from './agent-session-failure-words' @@ -94,18 +94,6 @@ function causeWords( return step ? { cause, step, action } : { cause, action } } -/** A reason whose honest words differ when it refuses a read of the chat's history. */ -type AgentSessionRefusalReasonRow = - | AgentSessionRefusalReasonWords - | { readHistory: AgentSessionRefusalReasonWords; write: AgentSessionRefusalReasonWords } - -// A read raises it only for a journal file no retry can read, so only a new chat continues. -const HISTORY_UNUSABLE: AgentSessionRefusalCauseWords = { - cause: 'historyUnusable', - step: 'startNewChat', - action: 'goElsewhere' -} - const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. @@ -207,8 +195,9 @@ const REASON_WORDS = { agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, agent_session_journal_unreadable: { - // A write's covers any open that failed, which can clear. - journalUnreadable: { readHistory: HISTORY_UNUSABLE, write: codeWords('hostFinding') } + // No retry reads past damage, so only a new chat continues. + journalCorrupt: causeWords('historyUnusable', 'goElsewhere', 'startNewChat'), + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') }, // Thrown, so a client meets these only as an RPC error; the code's words stand. structured_agent_session_unsupported: { @@ -220,32 +209,35 @@ const REASON_WORDS = { } satisfies { [C in AgentSessionWireRefusalCode]: Record< AgentSessionRefusalReason, - AgentSessionRefusalReasonRow + AgentSessionRefusalReasonWords > } -/** The words and next step a reason gets on this request; undefined when the refusal names none. */ +/** The words and next step a reason gets; undefined when the refusal names none. */ export function agentSessionRefusalReasonWords( - failure: AgentSessionWriteRefusal, - request: AgentSessionWriteKind + failure: AgentSessionWriteRefusal ): AgentSessionRefusalReasonWords | undefined { const reason = failure.details?.reason - const byReason: Partial> | undefined = + const byReason: Partial> | undefined = REASON_WORDS[failure.code] - const row = reason === undefined ? undefined : byReason?.[reason] - if (!row || !('readHistory' in row)) { - return row - } - return request === 'read-history' ? row.readHistory : row.write + return reason === undefined ? undefined : byReason?.[reason] } +// Each already says the history was not read. +const HISTORY_CAUSES: ReadonlySet = new Set([ + 'historyUnusable', + 'historyUnavailable', + 'historyUnreadable' +]) + /** A cause, and that the request did not happen unless the cause already says so. */ function causeParts( cause: AgentSessionWriteNoticeSentence, write: AgentSessionWriteKind ): AgentSessionWriteNoticeSentence[] { const saysNotDone = - cause === 'historyUnusable' || (cause === 'questionChanged' && write === 'answer') + (write === 'read-history' && HISTORY_CAUSES.has(cause)) || + (cause === 'questionChanged' && write === 'answer') return saysNotDone ? [cause] : [cause, NOT_DONE[write]] } @@ -262,7 +254,7 @@ function reasonParts( failure: AgentSessionWriteRefusal, write: AgentSessionWriteKind ): AgentSessionWriteNoticePart[] | undefined { - const words = agentSessionRefusalReasonWords(failure, write) + const words = agentSessionRefusalReasonWords(failure) if (!words || 'words' in words) { return undefined } @@ -319,11 +311,9 @@ export function agentSessionWriteNoticeParts( case 'agent_session_item_revision_stale': case 'agent_session_already_resolved': return causeParts('questionChanged', write) - // A read raises it with no reason, and only for a file no retry can read. + // A host that names no reason raised it for damage and for an open that can clear alike. case 'agent_session_journal_unreadable': - return write === 'read-history' - ? causeWordsParts(HISTORY_UNUSABLE, write) - : ['historyUnreadable', notDone] + return causeParts('historyUnreadable', write) case 'structured_agent_session_unsupported': return ['unsupported'] } diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 73373b4bb3d..e7d4bd75d66 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -25,6 +25,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { questionChanged: 'This question was already answered or has changed.', historyUnreadable: "Orca couldn't read this chat's saved history.", historyUnusable: "This chat's history couldn't be read, so it can't continue here.", + historyUnavailable: "Orca couldn't open this chat's history right now.", unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", unreachable: "Orca couldn't reach the agent.", recordFailed: "Orca couldn't record it in this chat's history.", @@ -54,7 +55,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { waitMoment: 'Wait a moment.', recordUnreadable: "Orca couldn't read this chat's saved state.", chatNotFound: 'The Orca running this chat has no record of it.', - startNewChat: START_NEW_CHAT + startNewChat: START_NEW_CHAT, + tryAgain: 'Try again.' } as const export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY From 8f834f1ec73d60355d6c711d6ef309614ff70da3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:04:22 -0700 Subject: [PATCH 111/122] docs(native-chat): say why a history that couldn't open names its retry step --- src/shared/agent-session-refusal-notice.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index fd15da09931..29a9139f986 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -197,6 +197,7 @@ const REASON_WORDS = { agent_session_journal_unreadable: { // No retry reads past damage, so only a new chat continues. journalCorrupt: causeWords('historyUnusable', 'goElsewhere', 'startNewChat'), + // Says its step despite 'retry': released clients and the phone often show no Retry here. journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') }, // Thrown, so a client meets these only as an RPC error; the code's words stand. From 53b2f636b6f52df768eca2cf5528293daae18e18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:30:46 -0700 Subject: [PATCH 112/122] fix(native-chat): a failed start's row keeps the words its rejected messages carry When the delivery loop settles a failed start before the child's exit is published, it writes the start's error row and rejects every queued message with the adapter's startup answer. The exit settlement then rewrote the same row from the exit event, so the row could say one thing while the rejected messages, which are terminal, said another. The exit now leaves a start's row it finds already written. --- ...-agent-session-accept-then-deliver.test.ts | 50 +++++++++++++++++++ ...gent-session-dead-generation-settlement.ts | 18 ++++--- ...uctured-agent-session-start-failure-row.ts | 19 ++++++- 3 files changed, 77 insertions(+), 10 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 14ad5311c68..c15d9232dd7 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 @@ -536,6 +536,56 @@ describe('a child that exits before its message is handed over', () => { }) }) +describe('a start whose failure the delivery loop settles before the exit is published', () => { + it('keeps one row in the words its rejected messages carry', async () => { + // The adapter's startup answer and its later exit event word the same start differently. + const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) + adapterExtras = { awaitStarted } + acquire.mockImplementation(async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const + })) + await host.close(SESSION) + await startHost() + + const first = await accept('first') + const second = await accept('second') + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'codex app-server exited with code 1', + failure: agentSessionFailureFact('providerStartFailed', { + detail: { text: 'codex: config.toml is invalid', audience: 'person' } + }), + cause: 'unexpected-exit', + startupUnproven: true + }) + await host.flushAllStreamedEvents() + await host.revealSession(SESSION) + + const rows = host + .journalSnapshot(SESSION) + .items.filter((item) => item.itemId.includes('start-failure')) + expect(rows).toHaveLength(1) + const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { + surface: 'rejection', + agentName: 'Codex', + provider: 'codex' + }) + expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) + for (const id of [first, second]) { + expect(submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: words.reason, + rejection: words.rejection + }) + } + }) +}) + describe('Stop withdraws what is queued', () => { it('withdraws a crash leftover 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 5072d14748b..d2e1e135d1a 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 @@ -18,7 +18,10 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' -import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' +import { + hasStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { runningTurnLifecycleRevisions, @@ -128,13 +131,12 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { - // Until views stop starting children, a start can die with nothing queued for the loop. - mutations.push( - structuredAgentSessionStartFailureRow( - input.exitedDuringStartup.generation ?? input.settlementId, - startupFailure - ) - ) + // Until views stop starting children, a start can die with nothing queued for the loop. A + // row the loop already wrote stays: its words are the ones its rejected messages carry. + const startKey = input.exitedDuringStartup.generation ?? input.settlementId + if (!hasStructuredAgentSessionStartFailureRow(items, startKey)) { + mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) + } } else if (showUnexpectedExitOutcome) { mutations.push({ kind: 'item', 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 10ce568b551..c0660eb9791 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,3 +1,4 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -12,7 +13,7 @@ export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFail * 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. + * lands on the same row instead of adding one. */ export function structuredAgentSessionStartFailureRow( startKey: string, @@ -20,12 +21,26 @@ export function structuredAgentSessionStartFailureRow( ): JournalLifecycleMutationInput { return { kind: 'item', - identity: { provider: 'orca', clientMessageId: `start-failure:${startKey}` }, + identity: startFailureRowIdentity(startKey), // The row repeats the sentence the start's rejected messages carry. body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection } } } +/** Whether the start's row is already written. Its words are the ones its rejected messages carry, + * and rejected is terminal, so the exit's later report of the same start must not reword it. */ +export function hasStructuredAgentSessionStartFailureRow( + items: readonly { itemId: string }[], + startKey: string +): boolean { + const itemId = agentJournalItemKey(startFailureRowIdentity(startKey)) + return items.some((item) => item.itemId === itemId) +} + +function startFailureRowIdentity(startKey: string) { + return { provider: 'orca' as const, clientMessageId: `start-failure:${startKey}` } +} + /** * 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 From b36f709b74b62f11c28061aa56b01ca135ed7933 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:53:03 -0700 Subject: [PATCH 113/122] fix(native-chat): a Claude start Orca itself failed no longer says Claude stopped Every error that ended a Claude session was marked as an exit the adapter observed, including a start Orca failed while the CLI was still running: a saved option whose restore lost its answer, an init frame naming another session, or a journal write fault. Those read "Claude stopped before it finished starting." although Claude never stopped on its own. The mark now stays where the child's exit is seen (the connection's exit callback), so those starts read "Claude couldn't start." with any diagnostic beside it, and a real exit before the start lands still says Claude stopped. --- ...laude-structured-session-exit-lifecycle.ts | 10 +- ...tured-startup-fault-is-not-an-exit.test.ts | 141 ++++++++++++++++++ ...ed-claude-scripted-runtime-test-support.ts | 15 +- 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 17a86c1b2e1..02643eb82a4 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,8 +1,5 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import { - providerStartupFailureFact, - withObservedProviderExit -} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, @@ -37,8 +34,9 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) - // Observed here: whoever meets this error later, a start it ended reads as the provider stopping. - failClaudeStartup(session, withObservedProviderExit(error)) + // Not marked here: startup and journal faults end the session this way too. The child's own + // exit arrives already marked by the connection's exit callback. + 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/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts new file mode 100644 index 00000000000..2a7ccc54521 --- /dev/null +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -0,0 +1,141 @@ +// A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't +// restore, or an init frame naming another session. Orca ends that child itself, so the chat must +// not say Claude stopped on its own; only an exit Orca saw says that. Against the production +// runtime, adapter, record store and host, with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-startup-fault' +const CALLER = { callerKey: 'client-1' } +const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STOPPED_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +function fence(host: StructuredAgentSessionHost): number { + return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence(host), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + return sent.ok ? sent.value.clientMessageId : '' +} + +function failureRows(host: StructuredAgentSessionHost) { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) +} + +function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return host + .journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + +async function released(host: StructuredAgentSessionHost): Promise { + await waitForStructuredAgentSessionRecovery() + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) +} + +describe('a Claude start that Orca fails while the CLI is still running', () => { + it('reads as a start that could not happen when a saved option cannot be restored', async () => { + claude.behave(SESSION, { optionWritesFail: true }) + const host = await claude.install() + await expect( + host.attach( + CALLER, + claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) + ) + ).resolves.toMatchObject({ ok: true }) + await released(host) + + expect(claude.child(SESSION).calls).toContain('set_permission_mode') + expect(failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + }) + + it('rejects a held message as a start that could not happen when init names another session', async () => { + claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).answerInit() + await released(host) + + await vi.waitFor(() => + expect(submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringMatching(/^Claude couldn't start\./), + rejection: { kind: 'startFailed' } + }) + ) + expect(failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + expect(claude.child(SESSION).calls).not.toContain('send') + }) + + it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { + claude.behave(SESSION, { initHangs: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) + await released(host) + + await vi.waitFor(() => + expect(submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: STOPPED_TEXT, + rejection: { kind: 'providerStartFailed' } + }) + ) + expect(failureRows(host)).toEqual([{ text: STOPPED_TEXT, kind: 'providerStartFailed' }]) + }) +}) diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index e68fdc84759..33aa18f0570 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -42,6 +42,11 @@ export type ScriptedClaudeBehavior = { optionWritesHang?: boolean /** Startup's own settings read goes unanswered. */ startupSettingsReadHangs?: boolean + /** Every option write loses its answer while the CLI keeps running (not a refusal), so a + * start that restores one faults. */ + optionWritesFail?: boolean + /** The init frame names another provider session than the one launched. */ + initNamesForeignSession?: boolean } export type ScriptedClaudeChild = { @@ -91,7 +96,13 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { const never = (): Promise => new Promise(() => {}) const optionWrite = (subtype: string): Promise => { child.calls.push(subtype) - return control(subtype, () => (behavior.optionWritesHang ? never() : Promise.resolve())) + return control(subtype, () => + behavior.optionWritesFail + ? Promise.reject(new Error('Query closed before response received')) + : behavior.optionWritesHang + ? never() + : Promise.resolve() + ) } let settingsReads = 0 const child: ScriptedClaudeChild = { @@ -115,7 +126,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { handlers.onMessage?.({ type: 'system', subtype: 'init', - session_id: providerSessionId, + session_id: behavior.initNamesForeignSession ? 'foreign-session' : providerSessionId, model: 'claude-sonnet-5', apiKeySource: 'none' }) From e8f0a5b31c766a7d7078d5b080b468967f80e47c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:40:44 -0700 Subject: [PATCH 114/122] fix(native-chat): name the agent that stopped, and blame Orca for its own closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat that lost its agent mid-response said "The provider stopped…", and a started Claude session that Orca itself closed after a journal fault said the same, as if Claude had exited on its own. The exit row and the rejected-message reason now name the chat's agent ("Claude stopped while this response was in progress…", "Codex stopped before this message was sent."), or "The agent" when the name is unknown; the stale-state settlement now passes the agent name too. After a Claude start has landed, the ended event reports providerExited only when the child's own exit was observed; any other close is Orca's fault and reads as one. --- ...aude-structured-session-exit-blame.test.ts | 66 +++++++++++++++++ ...laude-structured-session-exit-lifecycle.ts | 18 +++-- ...-agent-session-accept-then-deliver.test.ts | 2 +- ...ured-agent-session-attach-orchestration.ts | 7 +- ...uctured-agent-session-conversation-open.ts | 4 +- ...session-dead-generation-settlement.test.ts | 2 +- ...gent-session-dead-generation-settlement.ts | 7 +- ...gent-session-provider-child-record.test.ts | 11 ++- ...red-agent-session-surface-lifetime.test.ts | 2 +- ...ured-agent-session-unexpected-exit.test.ts | 10 +-- ...laude-structured-exit-mid-response.test.ts | 74 +++++++++++++++++++ .../agent-session-failure-words.test.ts | 16 +++- src/shared/agent-session-failure-words.ts | 6 +- 13 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 src/main/claude/claude-structured-session-exit-blame.test.ts create mode 100644 src/main/runtime/claude-structured-exit-mid-response.test.ts diff --git a/src/main/claude/claude-structured-session-exit-blame.test.ts b/src/main/claude/claude-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..79430131e15 --- /dev/null +++ b/src/main/claude/claude-structured-session-exit-blame.test.ts @@ -0,0 +1,66 @@ +// After a Claude start lands, only the child's own exit says Claude stopped; a fault on Orca's +// side that makes Orca close the child is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +async function startedClaude(sink: StructuredAgentSessionEventSink) { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + const ended = async () => { + await vi.waitFor(() => expect(events.some((event) => event.type === 'ended')).toBe(true)) + return events.find((event) => event.type === 'ended') + } + return { connection: claude.connections[0], ended } +} + +describe('what a started Claude session says ended it', () => { + it('says Claude stopped when the child exits on its own', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + }) + + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): killed')) + + expect(await ended()).toMatchObject({ + cause: 'unexpected-exit', + failure: { kind: 'providerExited' } + }) + }) + + it('blames Orca when a journal fault makes Orca close the child', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendResolvedItemAndPublish: () => ({ accepted: false, reason: 'failed' }) + }) + + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'task_notification', + session_id: PROVIDER_SESSION_ID, + task_id: 'task-1', + status: 'failed', + summary: 'failed' + }) + + expect(await ended()).toMatchObject({ + reason: 'claude background task journal sink failed', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + }) + expect(connection.closeCount).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 02643eb82a4..854646490a8 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,5 +1,8 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { + providerExitObserved, + providerStartupFailureFact +} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, @@ -89,11 +92,16 @@ export function settleClaudeUnexpectedExit( type: 'ended', sessionId, reason: exit.error.message, - // A start that never landed says why it failed; a running child's exit keeps its stderr. + // A start that never landed says why it failed. After it landed, only the child's own exit + // blames the provider; an Orca fault that closed it is Orca's. failure: - exit.session.startup.state === 'proven' - ? agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(exit.error) }) - : providerStartupFailureFact(exit.session.startup.failure ?? exit.error), + exit.session.startup.state !== 'proven' + ? providerStartupFailureFact(exit.session.startup.failure ?? exit.error) + : providerExitObserved(exit.error) + ? agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(exit.error) + }) + : agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, 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 c15d9232dd7..989f250ae4a 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 @@ -528,7 +528,7 @@ describe('a child that exits before its message is handed over', () => { await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) expect(submission(id)).toMatchObject({ - reason: 'The provider stopped before this message was sent.', + reason: 'Codex stopped before this message was sent.', rejection: { kind: 'providerExited' } }) expect(acquire).toHaveBeenCalledTimes(2) 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 3bd1da4df12..d4235426d6b 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 @@ -23,6 +23,7 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionProviderChild, @@ -131,7 +132,8 @@ async function runAttach( const attemptSink = context.runtimeState.mintEventSink(sessionId) // Read before the reserve clears it: how the previous generation ended decides how whatever it // left running is settled. - const priorDeathEvidence = context.deps.store.getRecord(sessionId)?.lease.deathEvidence ?? null + const priorRecord = context.deps.store.getRecord(sessionId) + const priorDeathEvidence = priorRecord?.lease.deathEvidence ?? null const attempt: { candidate: AttachCandidate | null; committed: boolean } = { candidate: null, committed: false @@ -188,7 +190,8 @@ async function runAttach( sessionId, fence, acquisitionGeneration, - deathEvidence: priorDeathEvidence + deathEvidence: priorDeathEvidence, + failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) }) } await bindAndDrain(eventSink, attached.journal, fence, (activity) => 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 07d1fb6b5ff..bce79ca71f8 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 @@ -21,6 +21,7 @@ import { 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 { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -112,7 +113,8 @@ export async function openStructuredAgentSessionConversationJournal( sessionId, fence, acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null + deathEvidence: record.lease.deathEvidence ?? null, + failureTextContext: structuredAgentSessionFailureWordsContext(record) }) } } catch (error) { 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 b60cb6662d4..4a0febb6ceb 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 @@ -17,7 +17,7 @@ import { } from './structured-agent-session-dead-generation-settlement' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'The provider stopped while this response was in progress. You can continue in this conversation.' + 'The agent stopped while this response was in progress. You can continue in this conversation.' const SESSION = 'session-dead-generation' const THREAD = 'thread-1' 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 d2e1e135d1a..130e57fc872 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 @@ -190,6 +190,8 @@ export async function settleStaleStructuredAgentSessionState(input: { fence: number acquisitionGeneration: string | null deathEvidence: AgentSessionDeathEvidence | null + /** Who the exit row names. */ + failureTextContext?: AgentSessionFailureWordsContext }): Promise { const { journal } = input const items = journal.snapshot().items @@ -213,7 +215,10 @@ export async function settleStaleStructuredAgentSessionState(input: { // that the provider stopped. body: { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { surface: 'row' }) + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...input.failureTextContext, + surface: 'row' + }) } }) } 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 4c258ae347e..d23c6b457f1 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 @@ -18,10 +18,7 @@ import { type AgentSessionFailureFact, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureSentence, - agentSessionFailureWords -} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' 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' @@ -357,7 +354,9 @@ describe('settling an earlier child before the next one takes its message', () = ) expect( items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain(agentSessionFailureSentence({ kind: 'providerExited' }, 'row')) + ).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) // 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) @@ -535,7 +534,7 @@ describe('a child that ends before its message is handed over', () => { detail: { text: 'codex app-server crashed', audience: 'log' } } expect(submission(id)).toMatchObject({ - reason: 'The provider stopped before this message was sent.', + reason: 'Codex stopped before this message was sent.', rejection: failure }) expect(statusRows()).toEqual([ 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 d9f299d5f78..9df1b6c0ff2 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 @@ -39,7 +39,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'The provider stopped while this response was in progress. You can continue in this conversation.' + 'Codex stopped while this response was in progress. You can continue in this conversation.' const CALLER = { callerKey: 'client-1' } const SURFACE = 'desktop-chat: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 dae8b15874c..d9d92203a66 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 @@ -16,8 +16,8 @@ import { type StructuredAgentSessionRecoveryTicket } from './structured-agent-session-unexpected-exit' -const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'The provider stopped while this response was in progress. You can continue in this conversation.' +const exitOutcome = (agent: string): string => + `${agent} stopped while this response was in progress. You can continue in this conversation.` const SESSION = 'session-1' const GENERATION = 'generation-1' @@ -264,7 +264,7 @@ describe('provider-exit recovery tickets', () => { }, body: { kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + text: exitOutcome('The agent'), failure: { kind: 'providerExited' } } }, @@ -355,7 +355,7 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ body: { kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + text: exitOutcome('Claude'), failure: { kind: 'providerExited' } } }) @@ -407,7 +407,7 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ body: { kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + text: exitOutcome('Claude'), failure: { kind: 'providerExited' } } }) diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts new file mode 100644 index 00000000000..12e246d55a2 --- /dev/null +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -0,0 +1,74 @@ +// A Claude CLI that exits on its own after its start landed, with a message handed to it: the chat +// says Claude stopped, by name. Against the production runtime, adapter, record store and host, +// with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-exit-mid-response' +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) +} + +describe('a started Claude CLI that exits while a response is in progress', () => { + it('says Claude stopped, and that the conversation can continue', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + + claude.child(SESSION).exit(scriptedClaudeExitError('claude stream-json exited (code 137)')) + await waitForStructuredAgentSessionRecovery() + + await vi.waitFor(() => + expect( + host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) + ).toEqual([ + { + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + kind: 'providerExited' + } + ]) + ) + }) +}) diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 13f24956ccf..859d3782cc8 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -149,12 +149,24 @@ describe('the words written beside a failure fact', () => { }) it('names the exit a row reports differently from the message it left unsent', () => { + expect( + agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row', agentName: 'Claude' }) + .text + ).toBe( + 'Claude stopped while this response was in progress. You can continue in this conversation.' + ) + expect( + agentSessionFailureWords( + { kind: 'providerExited' }, + { surface: 'rejection', agentName: 'Codex' } + ).reason + ).toBe('Codex stopped before this message was sent.') expect(agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row' }).text).toBe( - 'The provider stopped while this response was in progress. You can continue in this conversation.' + 'The agent stopped while this response was in progress. You can continue in this conversation.' ) expect( agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'rejection' }).reason - ).toBe('The provider stopped before this message was sent.') + ).toBe('The agent stopped before this message was sent.') }) it('says what frees a chat a terminal agent still holds, without naming its process', () => { diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 229bfaacd9e..9e31b5e14e4 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -172,10 +172,10 @@ const FAILURE_SENTENCES = { 'A Claude account switch is in progress. Try again after it finishes.', managedAccountUnsupported: (context) => `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then ${retryStep(context)}.`, - providerExited: (_, __, surface) => + providerExited: ({ agentName }, _, surface) => surface === 'row' - ? 'The provider stopped while this response was in progress. You can continue in this conversation.' - : 'The provider stopped before this message was sent.', + ? `${agentName ?? 'The agent'} stopped while this response was in progress. You can continue in this conversation.` + : `${agentName ?? 'The agent'} stopped before this message was sent.`, restartFailed: couldNot('restart'), providerRejected: (_, fact) => quotingPersonDetail('The provider did not accept this message', fact.detail), From 4b33dcf62788fc0fa096d58652b3d69caa633b98 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:56:27 -0700 Subject: [PATCH 115/122] test(native-chat): pin the sidebar verdict to the rejection classifier for every kind and legacy marker --- ...tured-agent-session-latest-request.test.ts | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 780a636064c..7cdb1b2d36a 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -4,14 +4,22 @@ import type { AgentJournalSubmission, AgentJournalTurnLifecycle } from './agent-session-journal-types' +import { AGENT_SESSION_FAILURE_KINDS, isSubmissionRejectionKind } from './agent-session-failure' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { + classifyDispatchRejection, DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, DISPATCH_REJECTED_HOST_RESTARTED, DISPATCH_REJECTED_NOT_DELIVERED, - DISPATCH_REJECTED_PROVIDER_CLOSED + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED } from './structured-agent-session-dispatch-rejection' -import { latestStructuredAgentSessionRequest } from './structured-agent-session-latest-request' +import { + hasStructuredAgentSessionRequest, + latestStructuredAgentSessionRequest +} from './structured-agent-session-latest-request' import { projectStructuredAgentSessionStatusSummary } from './structured-agent-session-projection' const START_FAILURE = 'Claude is not signed in.' @@ -245,3 +253,61 @@ describe('the latest request and its verdict', () => { }) }) }) + +describe('the sidebar verdict agrees with the rejection classifier', () => { + // A sentence as the reason, so only the typed fact can make a kind read as no verdict. + const typed = AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind).map((kind) => ({ + name: `typed ${kind}`, + submission: sent('m1', { + dispatchState: 'rejected', + reason: 'The message was not sent.', + rejection: { kind } + }) + })) + const legacy = [ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_WRITE_FAILED, + `${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe` + ].map((reason) => ({ name: `legacy ${reason}`, submission: rejected('m1', reason) })) + const unknownKind = { + name: 'a kind this build cannot place', + submission: sent('m1', { + dispatchState: 'rejected', + reason: 'A newer host wrote this.', + rejection: { kind: 'someFutureKind' } + }) + } + + it.each([...typed, ...legacy, unknownKind])('$name', ({ submission }) => { + const items = [userEntry('m1', 1)] + const { verdict } = classifyDispatchRejection(submission) + expect(latestStructuredAgentSessionRequest(items, [submission])?.outcome ?? null).toBe(verdict) + expect(hasStructuredAgentSessionRequest(items, [submission])).toBe(verdict === 'failure') + }) + + const failedNobody = new Set([ + 'typed notDelivered', + 'typed cancelled', + 'typed hostRestarted', + 'typed chatClosed', + `legacy ${DISPATCH_REJECTED_NOT_DELIVERED}`, + `legacy ${DISPATCH_REJECTED_CANCELLED}`, + `legacy ${DISPATCH_REJECTED_HOST_RESTARTED}`, + `legacy ${DISPATCH_REJECTED_PROVIDER_CLOSED}` + ]) + const failedNobodyCases = [...typed, ...legacy].filter(({ name }) => failedNobody.has(name)) + it('covers every send that failed nobody', () => { + expect(failedNobodyCases).toHaveLength(failedNobody.size) + }) + it.each(failedNobodyCases)( + 'gives no verdict for a send that failed nobody: $name', + ({ submission }) => { + expect(latestStructuredAgentSessionRequest([userEntry('m1', 1)], [submission])).toBeNull() + } + ) +}) From 71177934f570515ba174aff511b1bde6071c6768 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:01:42 -0700 Subject: [PATCH 116/122] fix(native-chat): blame Orca, not Codex, when Orca closes the Codex child A Codex chat that Orca itself closed (a journal sink that could not take a frame, or a forced close) said "Codex stopped while this response was in progress", as if Codex had exited on its own. Orca's own close path now reports hostFault. providerExited is left to the app-server connection's exit callback, which the connection withholds while Orca is closing the child, so it only ever reports the child's own exit. --- .../codex/codex-structured-session-close.ts | 11 ++- ...odex-structured-session-exit-blame.test.ts | 68 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/main/codex/codex-structured-session-exit-blame.test.ts diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index a109458216c..56e1855c60f 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -15,6 +15,9 @@ export function handleCodexSessionExit(input: { sessionId: string connection: CodexAppServerConnection | null error: Error + /** Set by Orca's own close. Absent only from the connection's onExit, which the connection + * withholds while Orca is closing the child. */ + closedByOrca?: true prompts?: CodexSession['prompts'] allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void @@ -30,9 +33,10 @@ export function handleCodexSessionExit(input: { type: 'ended', sessionId: input.sessionId, reason: input.error.message, - failure: agentSessionFailureFact('providerExited', { - detail: providerDiagnosticOf(input.error) - }), + // Only the child's own exit blames Codex; a close Orca made, for any reason, is Orca's. + failure: input.closedByOrca + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, @@ -97,6 +101,7 @@ export async function closeCodexPublishedSession( sessionId, connection: session.connection, error: options?.unexpectedReason ?? new Error('codex session closed'), + closedByOrca: true, prompts: session.prompts, ...(options?.allowFailedSettlement ? { allowFailedSettlement: true } : {}), ...(onEvent ? { onEvent } : {}) diff --git a/src/main/codex/codex-structured-session-exit-blame.test.ts b/src/main/codex/codex-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..591f0e61ae8 --- /dev/null +++ b/src/main/codex/codex-structured-session-exit-blame.test.ts @@ -0,0 +1,68 @@ +// Only the Codex app-server child's own exit says Codex stopped; a close Orca made — a journal +// sink that could not take a frame, a forced close — is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function sink(overrides: Partial = {}) { + return { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), ...overrides } +} + +async function startedCodex(events: StructuredAgentSessionEventSink) { + const codex = fakeCodex() + const emitted: CodexStructuredSessionEvent[] = [] + const adapter = adapterFor(codex, {}, emitted) + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 's', events }) + const ended = async () => { + await vi.waitFor(() => expect(emitted.some((event) => event.type === 'ended')).toBe(true)) + return emitted.filter((event) => event.type === 'ended') + } + return { adapter, connection: codex.connections[0], ended } +} + +describe('what a started Codex session says ended it', () => { + it('says Codex stopped when the child exits on its own', async () => { + const { connection, ended } = await startedCodex(sink()) + + connection.handlers.onExit?.(new Error('codex app-server connection ended: killed')) + + const [event] = await ended() + expect(event).toMatchObject({ cause: 'unexpected-exit', failure: { kind: 'providerExited' } }) + const failure = event && 'failure' in event ? event.failure : undefined + expect( + failure && agentSessionFailureWords(failure, { surface: 'row', agentName: 'Codex' }).text + ).toBe( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + }) + + it('blames Orca when a journal sink failure makes Orca close the child', async () => { + const { connection, ended } = await startedCodex( + sink({ tryAppendItem: () => ({ accepted: false, reason: 'failed' }) }) + ) + + connection.handlers.onUnhandledFrame?.('frame:invalid-json', '{') + + expect(await ended()).toMatchObject([ + { + reason: 'Codex provider frame frame:invalid-json could not be durably recorded (failed)', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + } + ]) + expect(connection.closeCount).toBe(1) + }) + + it('blames Orca for a forced close', async () => { + const { adapter, ended } = await startedCodex(sink()) + + await expect(adapter.forceCloseSession('session-1')).resolves.toBe(true) + + expect(await ended()).toMatchObject([ + { cause: 'unexpected-exit', failure: { kind: 'hostFault' } } + ]) + }) +}) From 99003be9ce76abbfeb9d51981f89e9d7235641de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:09:12 -0700 Subject: [PATCH 117/122] fix(native-chat): a chat whose history is damaged reads "Unable to load this chat." --- .../structured-agent-session-send-block.test.ts | 3 +-- .../structured-agent-session-send-preparation.ts | 2 +- src/renderer/src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/agent-session-refusal-notice.test.ts | 12 ++---------- src/shared/agent-session-refusal-notice.ts | 4 ++-- src/shared/agent-session-write-notice-copy.ts | 2 +- 12 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index 9604eaa0162..79882025889 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -97,8 +97,7 @@ describe('a write whose conversation the host could not open', () => { refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' }, - message: - "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue." + message: 'Unable to load this chat.' } }) }) 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 7a27a4ccefa..0b3eaf22092 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 @@ -86,7 +86,7 @@ export async function openConversationForWrite( // Released clients print a refusal's message for a send; it fits a Stop too. const JOURNAL_OPEN_MESSAGE: Record = { - journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable', 'startNewChat']), + journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f1a5544697b..77f3c16b1bc 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2726,7 +2726,7 @@ "goalsUnsupported": "This agent doesn't support goals.", "historyUnavailable": "Orca couldn't open this chat's history right now.", "historyUnreadable": "Orca couldn't read this chat's saved history.", - "historyUnusable": "This chat's history couldn't be read, so it can't continue here.", + "historyUnusable": "Unable to load this chat.", "hostReconciling": "Orca is still checking on this chat after restarting.", "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", "notDoneAnswer": "Your answer was not sent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0193b8a4ffb..d6d0523fe62 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17484,7 +17484,7 @@ "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "questionChanged": "This question was already answered or has changed.", "historyUnreadable": "Orca couldn't read this chat's saved history.", - "historyUnusable": "This chat's history couldn't be read, so it can't continue here.", + "historyUnusable": "Unable to load this chat.", "historyUnavailable": "Orca couldn't open this chat's history right now.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 071270d7316..b7adde11aa0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14943,7 +14943,7 @@ "outcomeUnknown": "Orca no pudo confirmar qué pasó. Revise el chat.", "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", - "historyUnusable": "No se pudo leer el historial de este chat, así que no puede continuar aquí.", + "historyUnusable": "No se puede cargar este chat.", "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3a199d115e8..877797e5814 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17426,7 +17426,7 @@ "outcomeUnknown": "Orca n'a pas pu confirmer ce qui s'est passé. Vérifiez le chat.", "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", - "historyUnusable": "L'historique de ce chat n'a pas pu être lu, il ne peut donc pas continuer ici.", + "historyUnusable": "Impossible de charger ce chat.", "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1b99122faf1..878ad768ff3 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17362,7 +17362,7 @@ "outcomeUnknown": "Orca は何が起きたかを確認できませんでした。チャットを確認してください。", "questionChanged": "この質問はすでに回答済みか、変更されています。", "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", - "historyUnusable": "このチャットの履歴を読み取れなかったため、ここでは続けられません。", + "historyUnusable": "このチャットを読み込めません。", "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c8c0b44d277..84eafa32f0a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17362,7 +17362,7 @@ "outcomeUnknown": "Orca가 무슨 일이 있었는지 확인하지 못했습니다. 채팅을 확인하세요.", "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", - "historyUnusable": "이 채팅의 기록을 읽지 못해 여기에서 계속할 수 없습니다.", + "historyUnusable": "이 채팅을 불러올 수 없습니다.", "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0e99b3cb877..0dfcdfd7820 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17327,7 +17327,7 @@ "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", "questionChanged": "此问题已被回答或已更改。", "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", - "historyUnusable": "无法读取此聊天的历史记录,因此无法在此继续。", + "historyUnusable": "无法加载此聊天。", "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 91d14da853b..092af1f5d25 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -483,16 +483,8 @@ describe('a chat whose history the host could not open', () => { ) it.each([ - [ - 'journalCorrupt', - 'read-history', - "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue." - ], - [ - 'journalCorrupt', - 'send', - "This chat's history couldn't be read, so it can't continue here. Your message was not sent. Start a new chat to continue." - ], + ['journalCorrupt', 'read-history', 'Unable to load this chat.'], + ['journalCorrupt', 'send', 'Unable to load this chat. Your message was not sent.'], [ 'journalUnavailable', 'read-history', diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 29a9139f986..818031b8191 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -195,8 +195,8 @@ const REASON_WORDS = { agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, agent_session_journal_unreadable: { - // No retry reads past damage, so only a new chat continues. - journalCorrupt: causeWords('historyUnusable', 'goElsewhere', 'startNewChat'), + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), // Says its step despite 'retry': released clients and the phone often show no Retry here. journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') }, diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index e7d4bd75d66..d0c36e27a40 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -24,7 +24,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", questionChanged: 'This question was already answered or has changed.', historyUnreadable: "Orca couldn't read this chat's saved history.", - historyUnusable: "This chat's history couldn't be read, so it can't continue here.", + historyUnusable: 'Unable to load this chat.', historyUnavailable: "Orca couldn't open this chat's history right now.", unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", unreachable: "Orca couldn't reach the agent.", From ad336b38b86dc3c7d7eac4a6027f25ddc7aa2ee2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:40:18 -0700 Subject: [PATCH 118/122] fix(native-chat): route the conversation-outlives-agent writers through the typed refusals Three writers that arrived with the merge wrote refusals the old way: - An operation that starts the agent itself, such as a goal change, turned any error the start threw into a refusal whose message was Orca's own error text, which released clients print. It now logs the error and says only that the agent couldn't restart. - An option picked while the chat is at rest, for a key the provider would not accept, is refused with the rejected-option reason, like the same pick on a running agent. - A restart continuation whose agent was refused a start filed the rejected message's sentence as the failure's reason. It files the refusal's code with its details again, which is what the restart-failure guidance keys on. --- .../structured-agent-session-agent-start.ts | 18 ++++-- ...nt-session-operation-start-refusal.test.ts | 61 +++++++++++++++++++ .../structured-agent-session-options-read.ts | 16 ++--- ...ured-agent-session-restart-continuation.ts | 18 +++++- ...tured-agent-session-restart-resume-host.ts | 12 +++- src/shared/agent-session-refusal-details.ts | 2 +- 6 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 52c8732c76e..a4c93fa00f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -13,9 +13,11 @@ import { agentSessionRefusalFromReference, readAgentSessionRefusalReference, refuse, + refuseUnclassified, type AgentSessionRefusalDetailsByCode, type AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import type { AgentSessionAttachResult, AgentSessionMutationResult, @@ -75,13 +77,17 @@ export function ensureStructuredAgentSessionAgentForOperation( context: StructuredAgentSessionAttachContext, sessionId: string ): Promise { - return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => ({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: error instanceof Error ? error.message : String(error) + return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { + // The error is Orca's own and goes to the log; the refusal says only that the start failed. + console.warn('[agent-session] starting the agent for an operation failed:', error) + return { + ok: false, + refusal: refuseUnclassified( + 'agent_session_owner_restart_failed', + agentSessionWriteNoticeEnglish(['restartFailed']) + ) } - })) + }) } async function startStructuredAgentSessionAgent( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts new file mode 100644 index 00000000000..7700a616b38 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -0,0 +1,61 @@ +// What an operation that needs the agent is told when it cannot have one: a typed refusal in +// words a person can read, never Orca's own error text. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' + +const SESSION = 'session-1' + +describe('an operation whose agent start throws', () => { + it('is refused as a failed restart, with the error only in the log', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json') + const context = { + sessions: new Map(), + reconcileLeases: () => Promise.reject(cause) + } + + const refused = await ensureStructuredAgentSessionAgentForOperation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any other member is read. + context as unknown as StructuredAgentSessionAttachContext, + SESSION + ) + + expect(refused).toEqual({ + ok: false, + refusal: { + code: 'agent_session_owner_restart_failed', + message: "The agent couldn't restart." + } + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('starting the agent'), cause) + warn.mockRestore() + }) +}) + +describe('an option picked while the chat is at rest', () => { + it('refuses a key the provider would not accept as a rejected option', async () => { + const persistOptions = vi.fn(async () => {}) + const refused = await recordStructuredAgentSessionOptionIntent( + { + getRecord: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. + ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + }, + { sessionId: SESSION, persistOptions, publish: () => {} }, + { key: 'notAnOption', value: 'x' } + ) + + expect(refused).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'optionRejected' } + } + }) + expect(persistOptions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index fd113dcdcfd..2391ffa0205 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -5,9 +5,10 @@ // exists. A pick made at rest is written to the record as intent, through the same transition a live // pick takes, so the next start applies it. -import type { - AgentSessionOptionResult, - AgentSessionOptionsResult +import { + refuse, + type AgentSessionOptionResult, + type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -64,10 +65,11 @@ export async function recordStructuredAgentSessionOptionIntent( if (!record || !accepted) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: `${record?.provider ?? 'This session'} has no session option named ${input.key}` - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${record?.provider ?? 'This session'} has no session option named ${input.key}` + ) } } const options = { ...record.options, [input.key]: input.value } 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 e96f2b77c7f..9dd04929385 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 @@ -7,6 +7,11 @@ // reports what happened. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, @@ -39,6 +44,8 @@ export type StructuredAgentSessionContinuationOutcome = { sessionId: string outcome: 'continued' | 'pending' | 'unknown' | 'refused' reason?: string + /** The refusal that kept the agent from starting; `reason` is then its code. */ + refusal?: AgentSessionRefusalReference } /** The slice of the host one continuation needs. Structural so this module never imports the host. */ @@ -129,7 +136,11 @@ export class RestartContinuationSupersededError extends AgentSessionPreDispatchE } } -type ContinuationSubmission = { dispatchState?: string; reason?: string | null } +type ContinuationSubmission = { + dispatchState?: string + reason?: string | null + rejection?: UnreadAgentSessionFailureFact +} export type StructuredAgentSessionContinuationDeps = { /** Runtime fence as it stands now; null when this host has no record of the session. */ @@ -310,10 +321,13 @@ function refusedBy( sessionId: string, submission: ContinuationSubmission ): StructuredAgentSessionContinuationOutcome { + // A start the agent was refused files that refusal's code, which the failure guidance keys on. + const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal return { sessionId, outcome: 'refused', - reason: submission.reason ?? 'agent_session_dispatch_rejected' + reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}) } } 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 058f6bc7c95..d053caf006b 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 @@ -3,6 +3,10 @@ // action removes them — or files what went wrong. Starting the chat's agent again withdraws them. import { randomUUID } from 'node:crypto' +import { + AgentSessionRefusalError, + agentSessionRefusalFromReference +} from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { @@ -231,8 +235,12 @@ export function createStructuredAgentSessionRestartResume( ) if ('done' in started) { continued.push(started.done) - if (started.done.outcome === 'refused') { - throw new Error(started.done.reason ?? 'agent_session_continuation_refused') + const { outcome, reason, refusal } = started.done + if (outcome === 'refused') { + // Thrown as a refusal so the filed failure keeps its details beside the code. + throw refusal + ? new AgentSessionRefusalError(agentSessionRefusalFromReference(refusal, refusal.code)) + : new Error(reason ?? 'agent_session_continuation_refused') } return } diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 697462c2eba..6080a772ee2 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -102,7 +102,7 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'hostDisabled', 'hostUnsupported' ], - // No host path sends it; older clients still read it. + // Sent with no reason when starting the agent for an operation throws; older clients read it too. agent_session_owner_restart_failed: [] } as const satisfies Record From 2b4f8c35493dab07e2a17ff209033cb38e78e575 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:43:40 -0700 Subject: [PATCH 119/122] fix(native-chat): a start Orca stopped because it never finished reads as that The idle sweep now stops an agent whose start never finished and rejects the messages waiting on it. The chat read "Orca ran into a problem, so this didn't go through. Try again." for that, because every host stop was worded as Orca's own fault. It now reads "Codex never finished starting, so Orca stopped it." in the chat's row and on each rejected message, carried as its own failure kind so newer clients can tell it apart. The message counts as failed, like any start that did not land. --- .../structured-agent-session-delivery-loop.test.ts | 13 ++++++++----- .../structured-agent-session-delivery-loop.ts | 4 ++-- src/shared/agent-session-failure-words.ts | 2 ++ src/shared/agent-session-failure.ts | 2 ++ ...uctured-agent-session-dispatch-rejection.test.ts | 2 ++ .../structured-agent-session-dispatch-rejection.ts | 5 ++++- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts index d2781398402..242daf74faf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -31,11 +31,14 @@ describe('what a child end means for the messages queued behind it', () => { expect(structuredAgentSessionEndedChildFailure(ended('user-stop', starting))).toBeNull() }) - it.each([false, true])("is Orca's fault after a host stop (during startup: %s)", (starting) => { - expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ - hostFault: true - }) - }) + it.each([false, true])( + 'is a start Orca stopped after a host stop (during startup: %s)', + (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ + failure: { kind: 'hostStopped' } + }) + } + ) it.each(['exit', 'attach-failed', 'evict'] as const)( 'carries the recorded failure of a %s, as a failed start while starting', 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 938c5ffabef..7dd17306105 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 @@ -270,8 +270,8 @@ function providerEndFailure( // Every end cause, so a new one does not compile until it says whether it fails what is queued. const ENDED_CHILD_FAILURE = { 'user-stop': () => null, - // The host stopping the child is Orca's cause, never the provider's. - 'host-stop': () => ({ hostFault: true }), + // The host stopping the child is Orca's cause, never the provider's: a start that never finished. + 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), exit: providerEndFailure, // The attach records its own fault as the end's failure. 'attach-failed': providerEndFailure, diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 9e31b5e14e4..f02d02abdea 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -196,6 +196,8 @@ const FAILURE_SENTENCES = { cancelUnconfirmed: () => 'Cancellation was not confirmed.', answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', hostFault: () => "Orca ran into a problem, so this didn't go through. Try again.", + hostStopped: ({ agentName }) => + `${agentName ?? 'The agent'} never finished starting, so Orca stopped it.`, providerRetrying: ({ agentName }, { retry }) => retry?.error === 'rate_limit' || retry?.status === 429 ? `${agentName ?? 'The agent'} is rate-limited and retrying.` diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index c5674747b2f..ed8b677b359 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -39,6 +39,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'cancelUnconfirmed', 'answerUnconfirmed', 'hostFault', + /** Orca stopped an agent whose start never finished. */ + 'hostStopped', /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying' ] as const diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index b74150bcaea..be16c8a3fbd 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -53,6 +53,8 @@ describe('classifyDispatchRejection', () => { ['hostRestarted', 'undelivered', null], ['chatClosed', 'undelivered', null], ['notDelivered', 'undelivered', null], + // Orca stopped a start that hung: the agent failed, and the message never reached it. + ['hostStopped', 'undelivered', 'failure'], ['writeFailed', 'transport', 'failure'], ['queueFull', 'transport', 'failure'] ] as const)('reads the written %s rejection as %s, verdict %s', (kind, category, verdict) => { diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 5e9aaadf428..3b9ff4fb025 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -82,6 +82,7 @@ const KIND_CATEGORY = { chatClosed: 'undelivered', notDelivered: 'undelivered', providerExited: 'undelivered', + hostStopped: 'undelivered', providerStartFailed: 'startFailed', startFailed: 'startFailed', notSignedIn: 'startFailed', @@ -120,7 +121,9 @@ const KIND_VERDICT = { emptyMessage: 'failure', queueFull: 'failure', writeFailed: 'failure', - hostFault: 'failure' + hostFault: 'failure', + // Orca stopped a start that hung: the agent failed. + hostStopped: 'failure' } satisfies Record /** The legacy markers, by the kind each stands for. */ From d07508afa386505faea057a710d7498d1b107434 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:56:36 -0700 Subject: [PATCH 120/122] test(native-chat): pin the merged close and host-stop rows to their typed facts The merge left two expectations on the old words: the close tests looked for the marker a close used to write, and the host-stop test for the host-fault sentence. A close now writes "The chat closed before this message was sent." with its fact, and a host stop the hostStopped sentence the constructor gives, whatever reason the stop carried. Also folds the conversation command's two imports from send preparation into one. --- ...ctured-agent-session-provider-child-record.test.ts | 11 ++++++----- .../structured-conversation-command.ts | 6 ++++-- .../methods/structured-agent-session-at-rest.test.ts | 6 +++++- 3 files changed, 15 insertions(+), 8 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 e0abd96dfd7..fb66a1e1a8f 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 @@ -716,8 +716,8 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) - it("fails the start after a host stop, as Orca's fault rather than the provider's (R2)", async () => { - const reason = 'Claude never finished starting, so Orca stopped it.' + it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { + const reason = 'the start watchdog fired' const second = await stoppedWhileStarting(() => host['serialize'](SESSION, () => stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { @@ -728,13 +728,14 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - const text = "Orca ran into a problem, so this didn't go through. Try again." + // The sentence is the constructor's, not the reason the stop was given. + const text = 'Codex never finished starting, so Orca stopped it.' expect(await submission(second)).toMatchObject({ reason: text, - rejection: { kind: 'hostFault' } + rejection: { kind: 'hostStopped' } }) expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostFault' } } + { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } ]) expect(dispatch).not.toHaveBeenCalled() }) 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 132d3d708ff..2075c24d7bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -16,7 +16,10 @@ import { } from './structured-agent-session-attach' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { openWithAgent } from './structured-agent-session-send-preparation' +import { + openWithAgent, + structuredAgentSessionFailureWordsContext +} from './structured-agent-session-send-preparation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { conversationCommandBlocked } from './structured-conversation-command-admission' @@ -29,7 +32,6 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredSessionCompactionResult } from './structured-session-compaction' /** A compaction that did not succeed, keeping only what the provider wrote for a person. */ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 8202f793289..b80e4f078ec 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -376,9 +376,13 @@ describe('every close withdraws what is queued (P2-29)', () => { ) await close() + // A close's rejection is a sentence with its fact beside it, never a marker. await vi.waitFor(() => - expect(JSON.stringify(reader)).toContain('provider_closed_before_delivery') + expect(JSON.stringify(reader)).toContain( + '"reason":"The chat closed before this message was sent.","submittedAt"' + ) ) + expect(JSON.stringify(reader)).toContain('"rejection":{"kind":"chatClosed"}') expect(rig.host.hasSession(SESSION)).toBe(false) expect(rig.adapter.acquire).not.toHaveBeenCalled() }) From f6bca9fa92f3127e96d809c82893cb78f8b6e31b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:10:58 -0700 Subject: [PATCH 121/122] fix(native-chat): a read of a chat this host cannot open says why Reads now reach a chat through one accessor, which refused a missing record and a provider this host does not run as a bare code with nothing beside it. Revealing the same chat already names those reasons, so a client could tell "this chat no longer exists" and "update Orca" apart there but not on the history or subscribe read that follows. The accessor now throws the same typed refusals. The wire code and message are unchanged; the reason rides only in the error's data, which released clients ignore. --- ...red-agent-session-conversation-lifetime.ts | 17 +++++++-- .../structured-agent-session-at-rest.test.ts | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 7dace99f1a6..e81eb9ae976 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -6,7 +6,10 @@ // because the queue is not reentrant. import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' -import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { + AgentSessionRefusalError, + agentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { abandonQueuedStructuredAgentSessionMessages, @@ -99,10 +102,14 @@ export function createStructuredAgentSessionConversationLifetime(host: { } const record = deps().store.getRecord(sessionId) if (!record) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) } if (!adapterSupportsRecord(deps().adapter, record)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } return serialize(sessionId, async () => { // Read at the open itself: a read queued before quit began runs after it. @@ -111,7 +118,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { } const session = await host.open(sessionId) if (!session) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) } return session }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index b80e4f078ec..a3d598a69d8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -26,6 +26,7 @@ import { restTestSend, type RestTestRig } from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import * as providerSupport from '../../../native-chat/agent-session-wire/structured-agent-session-provider-support' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -87,6 +88,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() setStructuredAgentSessionHost(null) await rig.dispose() }) @@ -198,6 +200,42 @@ describe('the accessor', () => { expect(rig.host.hasSession(SESSION)).toBe(false) }) + it('refuses a read it cannot open with the reason, under the same code and message', async () => { + const [missing] = await call('agentSession.history', { + sessionId: 'session-never-created', + direction: 'tail' + }) + expect(missing).toMatchObject({ + ok: false, + error: { + code: 'agent_session_identity_required', + message: 'agent_session_identity_required', + data: { + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + } + } + } + }) + + await restingChat() + vi.spyOn(providerSupport, 'adapterSupportsRecord').mockReturnValue(false) + const [unsupported] = await call('agentSession.history', { + sessionId: SESSION, + direction: 'tail' + }) + expect(unsupported).toMatchObject({ + ok: false, + error: { + // Not a passthrough code: released clients match the message, as before. + code: 'runtime_error', + message: 'structured_agent_session_unsupported', + data: { refusal: { details: { reason: 'hostUnsupported' } } } + } + }) + }) + it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() From 58cf1ee9a42beece0e9ad197ec8efccdf2168e0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:28:44 -0700 Subject: [PATCH 122/122] test(native-chat): a Claude retrying past the idle window keeps its conversation open Every api_retry frame publishes the journal, and that publish is the activity the idle sweep reads, so a retry run revised into one row still renews the clock on each attempt. --- .../claude-api-retry-idle-sweep.test.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/main/claude/claude-api-retry-idle-sweep.test.ts diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts new file mode 100644 index 00000000000..fc5d234ec87 --- /dev/null +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -0,0 +1,132 @@ +// A Claude that keeps retrying a refused request is working, so the idle sweep must not stop it. +// Every retry frame's publish is the activity the sweep reads, even though a run writes one row. +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 { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const SWEEP_MS = 5 +const RETRY_GAP_MS = 10 * 60_000 + +let root: string +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink | null +let closeSession: Mock> +let clock: number + +function apiRetry(attempt: number): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 600_000, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}` + } +} + +/** Long enough for many sweep ticks, so "still open" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-retry-sweep-')) + resetHostTestOperationIds() + sink = null + clock = NOW + closeSession = vi.fn(async () => true) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken, events }) => { + sink = events ?? null + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'admitted' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS }, + now: () => clock + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Claude retrying a refused request', () => { + it('keeps the conversation open past the idle window while retry frames arrive', async () => { + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + if (!sink) { + throw new Error('the host never handed the provider its event sink') + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: '1' }) + + // Five frames ten minutes apart: fifty minutes, well past the thirty-minute idle window. + for (let attempt = 1; attempt <= 5; attempt += 1) { + clock += RETRY_GAP_MS + translator.handle({ type: 'message', sessionId: SESSION, message: apiRetry(attempt) }) + await waitOutSeveralSweeps() + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + } + + const items = host['sessions'].get(SESSION)?.journal.snapshot().items ?? [] + const retryRows = items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerRetrying' + ) + expect(retryRows).toHaveLength(1) + expect(retryRows[0]?.body).toMatchObject({ + failure: { detail: { text: expect.stringContaining('"attempt":5') } } + }) + + // Once the frames stop, the same clock does let the sweep close it. + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + }) + }) +})