From 883b768bc789ea6d622abd6611b663e29402fd72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:39:05 -0700 Subject: [PATCH] A chat never starts writing a conversation a terminal is still writing, and restored panes respect open chats (#30930) * fix(native-chat): one ownership index with link roles, and a chat refuses to start while a terminal writes its conversation Each chat link now carries its role (head or earlier) and key; Session History names the chat continuing a conversation first, duplicate adoption checks only head links in both the pre-commit check and the store transaction, and terminal resumes stay refused exactly as before. The terminal-writer check now also runs inside acquireOwner before the spawn, refusing with the typed pre-spawn reason conversationOpenInTerminal. * fix(native-chat): hold a terminal start Orca issues until its terminal reports it An in-memory registry of managed terminal starts per conversation, registered in the same step as the resume admission (terminal.send with a resume command, createTerminal with a command), bound to the PTY execution that runs it, and ended only when that terminal's hooks report the conversation or the execution is gone. A chat's agent start refuses while an entry may hold its conversation. * fix(terminal): an automatic pane restore never resumes a conversation an open chat continues Resume recipes built from a pane's saved facts (cold restore, deferred and late reattach, sleeping wake, host mobile materialization) carry resumeOrigin 'saved'. The execution host decides before any command is issued: while an open chat continues the conversation, or a chat may be writing it, the pane starts its plain shell with the started-fresh notice and its saved claim is forgotten; with unreadable records only a saved restore is refused and its record kept. An admitted resume registers its managed start in the same step, so the chat's agent start refuses until the terminal reports it or exits. Clearing the last saved record now reaches disk. * Fix saved terminal resume ownership and effective launch results * Preserve conversation case and clear refused cold restore identity * fix: publish effective terminal launch results on attachment * fix: preserve terminal launch callback and candidate ownership * fix: keep existing terminal attachments observational * fix: consolidate terminal launch imports * test: document snapshot runtime fixture boundary * fix: preserve terminal launch identity types * fix: accept reattach payload policy sessions * fix: preserve terminal result validation contracts * refactor: separate terminal create contract * test: document partial terminal runtime fixtures * test: align sleeping resume store fixture * Fix terminal create type contracts * fix: return the terminal main started to its waiting view * fix: register reattached terminals before publishing their handles * Check unavailable-resume test fixture types * Complete sidebar resume test store fixture * test: bound saved resume ownership and refusal work * Settle refused saved resume claims from actual launch results * Fix saved resume test type contracts * test(e2e): capture exact terminal parking diagnostics * test: verify parked terminal with fresh host inventory * Consolidate agent session imports in remote terminal transport * Clarify terminal conversation refusal guidance * Admit note-only saved conversations without hiding live writers * Retain terminal writer proofs through final admission --- .github/workflows/performance-contracts.yml | 5 + config/scripts/vitest-node-runtime-files.mjs | 1 + config/vitest.performance.config.ts | 5 + .../mobile-native-chat-unsent-notices.test.ts | 16 + .../agent-hooks/server/server-tab-cleanup.ts | 30 ++ ...ructured-session-ownership.test-support.ts | 26 +- .../structured-session-ownership.test.ts | 36 ++ .../ai-vault/structured-session-ownership.ts | 52 ++- .../codex-structured-thread-open.test.ts | 41 ++ .../codex/codex-structured-thread-open.ts | 17 + .../pty-attach-view-joins-main-start.test.ts | 1 + src/main/ipc/pty-ipc-mock-registry.ts | 11 + src/main/ipc/pty-ipc-suite-environment.ts | 2 + .../pty-spawn-saved-resume-admission.test.ts | 183 ++++++++ .../pty/host-env/saved-resume-admission.ts | 332 +++++++++++++ src/main/ipc/pty/ipc/spawn-begin.ts | 8 +- .../pty/ipc/spawn-commit-run-facts.test.ts | 38 +- src/main/ipc/pty/ipc/spawn-commit.ts | 33 +- src/main/ipc/pty/ipc/spawn-env-codex.ts | 6 + src/main/ipc/pty/ipc/spawn-execute.ts | 58 ++- src/main/ipc/pty/ipc/spawn-run.ts | 43 +- src/main/ipc/pty/ipc/spawn-state.ts | 4 + src/main/ipc/pty/ipc/spawn-types.ts | 2 + src/main/ipc/pty/provider/liveness.ts | 13 +- .../pty/pty-codex-account-attribution.test.ts | 5 +- .../pty-daemon-spawn-agent-home-env.test.ts | 6 +- .../pty/pty-pane-materialization-race.test.ts | 6 + .../pty-spawn-codex-home-unavailable.test.ts | 3 +- ...-spawn-env-codex-resume-provenance.test.ts | 3 +- .../pty/runtime/spawn-commit-pty-size.test.ts | 12 +- .../pty/runtime/spawn-commit-response.test.ts | 33 +- .../ipc/pty/runtime/spawn-commit-response.ts | 7 +- .../runtime/spawn-commit-run-facts.test.ts | 56 ++- src/main/ipc/pty/runtime/spawn-early.ts | 2 + src/main/ipc/pty/runtime/spawn-execute.ts | 171 +++++-- .../spawn-preflight-saved-resume.test.ts | 416 +++++++++++++++++ src/main/ipc/pty/runtime/spawn-preflight.ts | 7 + src/main/ipc/pty/runtime/spawn-state.ts | 15 +- src/main/ipc/pty/runtime/spawn.ts | 91 +++- src/main/ipc/pty/spawn-commit-assembly.ts | 10 +- .../structured-agent-session-acquisition.ts | 11 +- ...structured-agent-session-adapter-errors.ts | 1 + .../structured-agent-session-attach-flow.ts | 3 + ...ured-agent-session-attach-orchestration.ts | 3 + ...red-agent-session-failed-create-refusal.ts | 6 +- .../structured-agent-session-failure-text.ts | 26 +- .../structured-agent-session-host-types.ts | 5 +- ...ctured-agent-session-provider-open.test.ts | 24 +- ...nt-session-terminal-writer-acquire.test.ts | 440 ++++++++++++++++++ .../structured-provider-session-ownership.ts | 57 ++- ...red-agent-session-history-adoption.test.ts | 9 +- ...ructured-agent-session-history-adoption.ts | 2 + .../structured-conversation-key.ts | 66 +++ src/main/providers/pty-spawn-result.ts | 3 +- .../agent-session-record-store-slot.ts | 20 + ...gent-session-reservation-admission.test.ts | 23 + .../agent-session-reservation-admission.ts | 17 +- ...rminal-create-host-session-binding.test.ts | 253 ++++++++++ .../hook-status-resume-sleep-ordering.test.ts | 112 +++++ .../runtime/host-sleep-resume-record.test.ts | 1 + ...session-tabs-targeted-agent-resume.spec.ts | 144 +++++- ...stream-extraction-characterization.test.ts | 4 + .../terminal/terminal-lifecycle-methods.ts | 1 + .../terminal-multiplex-initial-snapshot.ts | 19 +- .../terminal-multiplex-subscribe-frame.ts | 3 +- .../terminal-multiplex-escape-tail.test.ts | 3 + ...rminal-multiplex-pty-wait-capacity.test.ts | 20 +- .../terminal-multiplex-pty-wait-queue.test.ts | 11 +- ...minal-multiplex-resync-replay-trim.test.ts | 3 + .../rpc/terminal-multiplex-test-harness.ts | 2 + ...-requested-snapshot-unavailability.test.ts | 3 + .../runtime-pty-controller-contract.ts | 22 +- .../runtime/runtime-terminal-contracts.ts | 2 + .../runtime/runtime-terminal-state-records.ts | 11 +- .../get-agent-session-execution-namespace.ts | 1 + .../agent-session-resume-claims.test.ts | 4 + .../projection/agent-session-resume-claims.ts | 2 +- .../projection/restored-pane-materialize.ts | 28 +- .../structured-session-cli-writer-host.ts | 63 ++- .../structured-agent-cli-writers.test.ts | 214 ++++++++- .../runtime/structured-agent-cli-writers.ts | 52 ++- .../structured-agent-session-runtime.ts | 23 +- .../mobile-terminal-chat-fork-create.ts | 6 +- .../terminal/launch/mobile-terminal-create.ts | 37 +- .../launch/mobile-terminal-owned-create.ts | 47 +- .../terminal/launch/terminal-create.ts | 81 ++-- .../terminal/launch/terminal-launch-result.ts | 86 ++++ .../terminal/lifecycle/pty-registration.ts | 14 + .../recovery/resolve-terminal-pane.ts | 2 + .../stop/worktree-sleep-resume-recording.ts | 91 +++- .../workspace-process-listing-dependencies.ts | 4 + .../workspaces/workspace-process-listing.ts | 1 + src/preload/api/pty-api.ts | 8 + src/preload/api/pty-bridge-session-control.ts | 9 + ...ession.approval-and-start-failure.test.tsx | 10 + .../agent-session-failure-words-text.test.ts | 28 ++ .../agent-session-failure-words-text.ts | 11 + ...ault-session-resume-in-chat-launch.test.ts | 2 +- .../tab-bar/cli-chat-resume-feedback.ts | 8 + .../tab-bar/cli-chat-resume-run.test.ts | 4 +- .../terminal-pane/ipc-pty-connect-result.ts | 21 +- .../terminal-pane/ipc-pty-connect.ts | 13 +- .../terminal-pane/ipc-pty-spawn-request.ts | 4 + ...ty-connection-agent-session-resume.spec.ts | 13 + ...nnection-cold-restore-agent-resume.spec.ts | 19 +- .../pty-connection-test-store-state.ts | 1 + .../terminal-pane/pty-connection-types.ts | 4 + .../pty-connection/apply-reattach-payload.ts | 45 +- .../cold-restore-resume-startup.ts | 44 ++ .../pty-connection/connect-pane-pty.ts | 3 + .../deferred-cold-restore-and-snapshot.ts | 45 +- .../deferred-session-reattach-connect.ts | 20 +- ...session-reattach-newer-daemon-hold.test.ts | 35 +- .../empty-reattach-retire-evidence.test.ts | 1 + .../pty-connection/fresh-spawn-start.ts | 55 ++- .../pty-connection/fresh-spawn-types.ts | 7 + .../hidden-agent-resume-deferral.ts | 3 + .../pty-connection/pane-serializer-settle.ts | 10 +- .../pty-connection/pane-transport-options.ts | 3 + .../reattach-payload-context.test.ts | 152 +++++- .../reattach-payload-session.ts | 4 + ...result-handler-park-reveal-verdict.test.ts | 15 +- .../pty-connection/reattach-result-handler.ts | 41 +- .../reattach-success-settle.test.ts | 14 +- .../saved-resume-cancellation.test.ts | 396 ++++++++++++++++ .../saved-resume-startup-cancellation.ts | 108 +++++ .../pty-connection/sleeping-record-access.ts | 30 +- .../pty-connection/spawn-deferred-sleep.ts | 19 +- .../terminal-launch-result-policy.ts | 95 ++++ .../pty-connection/title-spawn-bell.ts | 19 +- .../transport-output-callbacks.ts | 21 +- .../terminal-pane/pty-transport-types.ts | 10 +- ...ntime-pty-transport-create-handoff.test.ts | 79 +++- ...-pty-transport-host-session-launch.test.ts | 58 +++ ...runtime-pty-transport-spawn-intent.test.ts | 48 +- .../remote-runtime-pty-transport.ts | 135 +++++- ...mote-runtime-saved-result-consumer.test.ts | 390 ++++++++++++++++ .../ipc-events/agent-status-listeners.ts | 42 +- ...-status-refused-resume.performance.test.ts | 155 ++++++ .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/pt.json | 4 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../src/lib/cli-conversation-exit-guidance.ts | 4 +- .../src/lib/forget-refused-pane-resume.ts | 66 +++ ...-agent-session-launch-layout-owner.test.ts | 1 + ...ent-session-launch-windows-quoting.test.ts | 1 + .../src/lib/sleeping-agent-session-launch.ts | 14 + .../src/lib/workspace-session-patch.test.ts | 6 +- .../src/lib/workspace-session-patch.ts | 3 +- ...mote-runtime-terminal-multiplexer-types.ts | 5 +- ...te-runtime-terminal-response-controller.ts | 7 +- .../src/store/terminals/terminal-actions.ts | 2 + .../src/store/terminals/terminal-state.ts | 3 + src/shared/agent-session-failure-copy.ts | 4 + src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 2 + src/shared/agent-session-host-authority.ts | 1 + src/shared/agent-session-refusal-details.ts | 3 + .../agent-session-refusal-reason-words.ts | 2 + src/shared/agent-session-resume.ts | 43 ++ src/shared/agent-status-ipc-payload.ts | 10 + src/shared/agent-status-types.ts | 3 +- src/shared/protocol-version.ts | 4 + .../rpc-contract/agent-session-params.ts | 1 + .../rpc-contract/terminal-unary-params.ts | 2 + src/shared/runtime-terminal-contracts.ts | 27 +- .../runtime-terminal-create-contract.ts | 27 ++ ...ctured-agent-session-dispatch-rejection.ts | 4 + .../terminal-attachment-launch-result.ts | 56 +++ .../workspace-session-sleeping-agents.ts | 2 +- .../host-terminal-runtime-stub.ts | 2 + ...terminal-materialization-reconnect.spec.ts | 70 ++- ...untime-rejected-input-remount.unit.test.ts | 3 + 178 files changed, 6034 insertions(+), 537 deletions(-) create mode 100644 src/main/ipc/pty-spawn-saved-resume-admission.test.ts create mode 100644 src/main/ipc/pty/host-env/saved-resume-admission.ts create mode 100644 src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts create mode 100644 src/main/native-chat/structured-conversation-key.ts create mode 100644 src/main/runtime/terminal/launch/terminal-launch-result.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts create mode 100644 src/renderer/src/lib/forget-refused-pane-resume.ts create mode 100644 src/shared/runtime-terminal-create-contract.ts create mode 100644 src/shared/terminal-attachment-launch-result.ts diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml index 72946ababa2..58033f26c4e 100644 --- a/.github/workflows/performance-contracts.yml +++ b/.github/workflows/performance-contracts.yml @@ -16,6 +16,11 @@ on: - '!config/scripts/renderer-scrollbar-style-plugin.test.mjs' # Keep in sync with the contract list in config/vitest.performance.config.ts; # without these a rename lands green and only breaks the next nightly. + - 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts' + - 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts' + - 'src/main/runtime/hook-status-resume-sleep-ordering.test.ts' + - 'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts' + - 'src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts' - 'src/main/sqlite/sync-database.test.ts' - 'src/main/runtime/orchestration/db/row-column-lists.test.ts' - 'src/shared/native-chat/tools/native-chat-tool-attribution-allocation.test.ts' diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 48b32e5010a..ca53ed783dc 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -156,6 +156,7 @@ const NODE_RUNTIME_INCLUDE = [ 'src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts', 'src/renderer/src/store/github/cache-persistence.test.ts', 'src/main/native-chat/agent-session-journal/**/*.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index df26313b144..fed668dfafd 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -5,6 +5,11 @@ import baseConfig from './vitest.config' import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool' const contracts = [ + 'src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts', + 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts', + 'src/main/runtime/hook-status-resume-sleep-ordering.test.ts', + 'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts', 'src/shared/tui-agent-permission-choice.test.ts', 'src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx', 'src/main/runtime/rpc/methods/repo.test.ts', diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts index edc920a84dc..74a2574046c 100644 --- a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -48,6 +48,22 @@ const startRow: AgentJournalRenderItem = { } describe("the phone's line under a not-sent message", () => { + it.each([ + ['claude', 'Claude'], + ['codex', 'Codex'] + ])('names %s when its terminal holds the conversation', (agent, name) => { + const notices = mobileNativeChatUnsentNotices( + { + items: [], + submissions: [submission('held', { rejection: { kind: 'conversationOpenInTerminal' } })] + }, + agent + ) + expect(notices.get(agentJournalSubmissionKey('held'))).toBe( + `${name} is already running this conversation in another terminal. Quit ${name} there or close that terminal to continue here.` + ) + }) + it("words each rejected message as the desktop does, naming the chat's agent", () => { const notices = mobileNativeChatUnsentNotices( { diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 67094fe706f..3881499c18f 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -3,8 +3,38 @@ import { paneCacheKeyMatchesTab } from './server-status-identity' import { AgentHookServerCleanup } from './server-cleanup' import type { EnrichedAgentHookEventPayload } from './server-types' import { releaseAgentExitKey } from './agent-exit-evidence' +import type { RefusedPaneResume } from '../../../shared/agent-status-types' +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { + /** The host refused `paneKey`'s saved resume because a native chat is its conversation's home: + * forget the pane's claim here, with no resumable remnant, and tell renderers to forget theirs. */ + forgetRefusedPaneResume(paneKey: string, refused: RefusedPaneResume): void { + // A moved pane's row, and the renderer's state for it, live under its owner's key. + const ownerKey = this.resolvePaneKeyAlias(paneKey) + const row = this.state.lastStatusByPaneKey.get(ownerKey) + const clear = { paneKey: ownerKey, refusedResume: refused } + if ( + row && + 'receivedAt' in row && + typeof row.receivedAt === 'number' && + 'stateStartedAt' in row && + typeof row.stateStartedAt === 'number' && + refused.expectedStatus && + row.receivedAt <= refused.expectedStatus.receivedAt && + row.stateStartedAt === refused.expectedStatus.stateStartedAt && + row.payload.agentType === refused.agent && + agentProviderSessionsEqual(refused.agent, row.providerSession, refused.providerSession) + ) { + this.dropStatusEntry(ownerKey, { preserveResumeIdentity: false }) + this.emitPaneStatusCleared(clear) + return + } + // Renderers only: the pane's row is another conversation's, which other subscribers keep. + this.onPaneStatusCleared?.(clear) + } + + /** Drop every status/cache claim attributable to a closed tab prefix. */ dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) this.clearTmuxTabSubjects(tabId) diff --git a/src/main/ai-vault/structured-session-ownership.test-support.ts b/src/main/ai-vault/structured-session-ownership.test-support.ts index aec42fcf804..6da15e055b2 100644 --- a/src/main/ai-vault/structured-session-ownership.test-support.ts +++ b/src/main/ai-vault/structured-session-ownership.test-support.ts @@ -16,12 +16,12 @@ import { export const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' export function installOwnership( - overrides: Partial & { + overrides: Partial> & { handle?: AgentSessionProviderHandle } = {} ): void { const { handle, ...ownershipOverrides } = overrides - const ownership: StructuredProviderSessionOwnership = { + const ownership: Omit = { sessionId: 'session-alpha', workspaceId: 'workspace-1', provider: handle?.agent ?? 'codex', @@ -86,6 +86,28 @@ export function installCopyingChat( } as never) } +/** A live Codex chat whose chain held `threadIds` in order; the last is its head. */ +export function codexChatHolding(sessionId: string, threadIds: string[]): AgentSessionRecord { + const lease = { ...agentSessionLeaseFixture(), sessionId } + const fixture = agentSessionRecordFixture(lease) + return { + ...fixture, + sessionId, + provider: 'codex', + providerHandleChain: threadIds.map((threadId, index) => ({ + ...fixture.providerHandleChain[0]!, + linkId: `${sessionId}-${index}`, + handle: codexProviderHandle(threadId) + })), + lease + } +} + +export function installChats(records: AgentSessionRecord[]): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. + setStructuredAgentSessionHost({ deps: { store: { listRecords: () => records } } } as never) +} + export function listResult(): AiVaultListResult { const session: AiVaultSession = { id: `local:codex:${PROVIDER_SESSION}`, diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 31d54b46854..098ef959aef 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -4,12 +4,15 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { assertLegacyAiVaultResumeAllowed, assertLegacyAiVaultResumeCommandAllowed, + findResumeOwnership, OPENS_EVERY_STRUCTURED_CHAT, projectStructuredAiVaultSearchResponse, projectStructuredAiVaultSessions } from './structured-session-ownership' import type { AiVaultSearchHit, AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import { + codexChatHolding, + installChats, installOwnership, listResult, PROVIDER_SESSION @@ -107,6 +110,39 @@ describe('structured AI Vault ownership', () => { } ) + it('names the chat continuing a conversation first, while every link still refuses a terminal', async () => { + const movedOn = codexChatHolding('session-moved', [PROVIDER_SESSION, OTHER_SESSION]) + const owned = (records: ReturnType[]) => { + installChats(records) + return projectStructuredAiVaultSessions(listResult(), OPENS_EVERY_STRUCTURED_CHAT).sessions[0] + ?.structuredSession?.sessionId + } + expect(owned([movedOn, codexChatHolding('session-now', [PROVIDER_SESSION])])).toBe( + 'session-now' + ) + expect(owned([movedOn])).toBe('session-moved') + expect( + findResumeOwnership({ agent: 'codex', sessionId: PROVIDER_SESSION, filePath: '' }) + ).toMatchObject({ + sessionId: 'session-moved', + role: 'earlier' + }) + expect(() => + assertLegacyAiVaultResumeAllowed({ + agent: 'codex', + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + executionHostId: 'local' + }) + ).toThrow('agent_session_conflict') + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `codex resume ${PROVIDER_SESSION}`, + async () => undefined + ) + ).rejects.toThrow('agent_session_conflict') + }) + it('derives typed refusals from the single writer predicate for live and proving leases', async () => { installOwnership() expect(() => diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 9b6575a1e52..4f5688298b6 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -9,6 +9,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' import { listStructuredProviderSessionOwnership, + terminalResumeBlocked, type StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' @@ -122,18 +123,16 @@ function ownershipLookup(): const byProviderSession = new Map() for (const ownership of listOwnership()) { const key = `${ownership.provider}\0${ownership.providerSessionId}` - if (!byProviderSession.has(key)) { + const kept = byProviderSession.get(key) + if (!kept || (kept.role !== 'head' && ownership.role === 'head')) { byProviderSession.set(key, ownership) } } return (row) => { - for (const owner of historyRowOwners(row.agent)) { - const ownership = byProviderSession.get(`${owner}\0${row.sessionId}`) - if (ownership) { - return ownership - } - } - return null + const found = historyRowOwners(row.agent).flatMap( + (owner) => byProviderSession.get(`${owner}\0${row.sessionId}`) ?? [] + ) + return found.find((ownership) => ownership.role === 'head') ?? found[0] ?? null } } @@ -154,9 +153,9 @@ function historyRowOwners(rowAgent: string): readonly StructuredAgentId[] { export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { // A fork writes a new conversation, and preparing its home never writes this one. - const ownership = args.fork ? null : findResumeOwnership(args) - if (ownership) { - refuseLegacyWriter(ownership) + const blocking = args.fork ? undefined : resumeOwnerships(args).find(terminalResumeBlocked) + if (blocking) { + refuseLegacyWriter(blocking) } } @@ -169,12 +168,11 @@ export async function assertLegacyAiVaultResumeCommandAllowed( return } await ensureStructuredAgentSessionHostUnlessRefused(ensureHost) - const host = getStructuredAgentSessionHost() - if (!host) { - return - } for (const ownership of listOwnership()) { - if (invocations.some((invocation) => resumesOwnedSession(invocation, ownership))) { + if ( + terminalResumeBlocked(ownership) && + invocations.some((invocation) => resumesOwnedSession(invocation, ownership)) + ) { refuseLegacyWriter(ownership) } } @@ -183,19 +181,25 @@ export async function assertLegacyAiVaultResumeCommandAllowed( export function findResumeOwnership( args: Pick ): StructuredProviderSessionOwnership | null { + return resumeOwnerships(args)[0] ?? null +} + +/** Every link naming the row, those matched by id first. */ +function resumeOwnerships( + args: Pick +): StructuredProviderSessionOwnership[] { const owners = new Set(historyRowOwners(args.agent)) if (owners.size === 0) { - return null + return [] } const owned = listOwnership().filter((ownership) => owners.has(ownership.provider)) const fileName = args.filePath.split(/[\\/]/).at(-1) ?? '' - return ( - (args.sessionId - ? owned.find((ownership) => ownership.providerSessionId === args.sessionId) - : undefined) ?? - owned.find((ownership) => fileName.includes(ownership.providerSessionId)) ?? - null - ) + return [ + ...(args.sessionId + ? owned.filter((ownership) => ownership.providerSessionId === args.sessionId) + : []), + ...owned.filter((ownership) => fileName.includes(ownership.providerSessionId)) + ] } function listOwnership(): StructuredProviderSessionOwnership[] { diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index 9bb7211ef8e..7c9c1bc01d2 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -1,3 +1,4 @@ +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { describe, expect, it, vi } from 'vitest' import { CodexAppServerFrameSizeError } from './app-server/codex-app-server-frame-size-error' import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error' @@ -316,3 +317,43 @@ describe('openCodexThread', () => { }) }) }) + +it('keeps an exact active-writer refusal typed through cleanup wrapping', async () => { + const error = new CodexAppServerRequestError( + 'thread/resume', + -32600, + 'thread C already has an active writer' + ) + const request = vi.fn().mockRejectedValue(error) + let refused: unknown + try { + await openCodexThread(connectionFor(request), { cwd: '/workspace', resumeThreadId: 'C' }, 2_000) + } catch (caught) { + refused = caught + } + expect(refused).toMatchObject({ reason: 'conversationOpenElsewhere' }) + expect(providerStartupFailureFact(new AggregateError([refused], 'cleanup failed'))).toMatchObject( + { + kind: 'conversationOpenElsewhere', + detail: { text: error.message, audience: 'log' } + } + ) + expect(request).toHaveBeenCalledOnce() +}) + +it.each([ + ['thread/resume', -32600, 'thread D already has an active writer'], + ['thread/start', -32600, 'thread C already has an active writer'], + ['thread/resume', -32600, 'thread C is archived'], + ['thread/resume', -32600, 'unrelated invalid request'], + ['thread/resume', -1, 'thread C already has an active writer'] +] as const)('does not broaden writer refusal for %s/%s/%s', async (method, code, message) => { + const error = new CodexAppServerRequestError(method, code, message) + await expect( + openCodexThread( + connectionFor(vi.fn().mockRejectedValue(error)), + { cwd: '/workspace', resumeThreadId: 'C' }, + 2_000 + ) + ).rejects.toBe(error) +}) diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 0e5ac2937c9..edc57c33dad 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -1,3 +1,5 @@ +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error' import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types' @@ -88,6 +90,21 @@ export async function openCodexThread( ) } catch (error) { throwIfSignalAborted(signal) + if ( + isCodexAppServerRequestError(error) && + error.method === 'thread/resume' && + error.code === -32600 && + /\bthread\s+(\S+)\s+already has an active writer\b/i.exec(error.message)?.[1] === + resumeThreadId + ) { + throw withProviderDiagnostic( + Object.assign( + new AgentSessionAcquisitionRefusal(error.message, 'conversationOpenElsewhere'), + { cause: error } + ), + providerDiagnostic(error.providerDiagnostic?.text ?? error.message, 'log') + ) + } if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) { throw error } diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts index b503581a20d..0914a451ea0 100644 --- a/src/main/ipc/pty-attach-view-joins-main-start.test.ts +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -103,6 +103,7 @@ describe('a window view of a pane main is starting', () => { controller = value }), createPreAllocatedTerminalHandle: vi.fn(() => 'term_main'), + getAgentProviderSessionRows: vi.fn(() => []), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 703c154f1aa..9d52277fc57 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -30,6 +30,7 @@ export const openCodeClearPtyMock: Mock = vi.fn() export const buildAgentHookEnvMock: Mock = vi.fn() export const clearAgentHookPaneStateMock: Mock = vi.fn() export const registerPaneKeyAliasMock: Mock = vi.fn() +export const forgetRefusedPaneResumeMock: Mock = vi.fn() export const piBuildPtyEnvMock: Mock = vi.fn() export const piClearPtyMock: Mock = vi.fn() export const trackMock: Mock = vi.fn() @@ -127,11 +128,21 @@ export const mimoHookServiceModuleMock = () => ({ } }) +/** What a spawn's resume admission reads from a fake runtime: no chats, so every resume runs. */ +export const noChatsRuntimeMembers = () => ({ + openAgentSessionRecordStore: vi.fn(async () => ({ + listRecords: () => [], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) + })) +}) + export const agentHookServerModuleMock = () => ({ agentHookServer: { buildPtyEnv: buildAgentHookEnvMock, clearPaneState: clearAgentHookPaneStateMock, registerPaneKeyAlias: registerPaneKeyAliasMock, + forgetRefusedPaneResume: forgetRefusedPaneResumeMock, + recordLaunchResumeIdentity: () => {}, clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock } }) diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 342718ed9d1..b1e32e7347f 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -28,6 +28,7 @@ import { buildAgentHookEnvMock, clearAgentHookPaneStateMock, registerPaneKeyAliasMock, + forgetRefusedPaneResumeMock, piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, @@ -148,6 +149,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { buildAgentHookEnvMock.mockReset() clearAgentHookPaneStateMock.mockReset() registerPaneKeyAliasMock.mockReset() + forgetRefusedPaneResumeMock.mockReset() piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() diff --git a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts new file mode 100644 index 00000000000..7143689445a --- /dev/null +++ b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, vi } from 'vitest' +import { forgetRefusedPaneResumeMock, spawnMock } from './pty-ipc-mock-registry' +import { posixOnlyIt } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' +import { registerPtyHandlers } from './pty/register-handlers' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +// #26417: a pane that saved a chat's conversation must not resume it while the chat is its home. +describe('a pane restore of a conversation a native chat continues', () => { + const { handlers, mainWindow, createMockProc } = setupPtyIpcSuite() + const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) + ) + const C = 'provider-session-alpha-1' + const LEAF_ID = '11111111-1111-4111-8111-111111111111' + + function register(openTabs: string[] | 'unreadable') { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'openAgentSessionRecordStore').mockImplementation(async () => { + if (openTabs === 'unreadable') { + throw new Error('records unavailable') + } + const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + store.listRecords = () => [chat] + store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: openTabs }) + return store + }) + registerPtyHandlers(mainWindow, runtime) + return runtime + } + + async function spawnResume(resumeOrigin?: 'saved', leafId = LEAF_ID) { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + vi.useFakeTimers() + try { + const result = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + worktreeId: 'wt-1', + tabId: 'tab-floating', + leafId, + command: `claude --resume '${C}'`, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: C }, + ...(resumeOrigin ? { resumeOrigin } : {}) + }) + for (let flush = 0; flush < 3; flush += 1) { + await Promise.resolve() + vi.runAllTimers() + } + const env: Record = spawnMock.mock.calls.at(-1)![2].env + const typed = mockProc.proc.write.mock.calls.map((call) => String(call[0])) + const reported = typeof result === 'object' && result !== null ? result : {} + return { + refused: 'agentResumeRefused' in reported ? reported.agentResumeRefused : undefined, + notice: 'agentResumeUnavailable' in reported, + input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n') + } + } finally { + vi.useRealTimers() + } + } + + posixOnlyIt( + 'starts the plain shell with the notice and forgets the pane claim while the chat tab is open', + async () => { + register([chat.sessionId]) + const restored = await spawnResume('saved') + expect(restored).toMatchObject({ refused: 'chat-home', notice: true }) + expect(restored.input).not.toContain(C) + expect(forgetRefusedPaneResumeMock).toHaveBeenCalledWith(`tab-floating:${LEAF_ID}`, { + agent: 'claude', + providerSession: { key: 'session_id', id: C } + }) + } + ) + + posixOnlyIt('keeps a committed refused shell usable when status cleanup fails', async () => { + register([chat.sessionId]) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + forgetRefusedPaneResumeMock.mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + try { + const restored = await spawnResume('saved') + expect(restored).toMatchObject({ refused: 'chat-home', notice: true }) + expect(restored.input).not.toContain(C) + expect(warning).toHaveBeenCalled() + } finally { + warning.mockRestore() + } + }) + + posixOnlyIt( + 'resumes once the chat tab is closed, with the accepted pre-report interval', + async () => { + const runtime = register([]) + const restored = await spawnResume('saved') + expect(restored.refused).toBeUndefined() + expect(restored.input).toContain(`--resume '${C}'`) + await expect(runtime['assertStructuredChatAcquireAdmissible'](chat)).resolves.toBeUndefined() + } + ) + + posixOnlyIt( + 'with unreadable records refuses only a saved restore, and keeps its record', + async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + register('unreadable') + const restored = await spawnResume('saved') + const asked = await spawnResume(undefined, '22222222-2222-4222-8222-222222222222') + expect(restored.refused).toBe('unverifiable') + expect(restored.input).not.toContain(C) + expect(asked.refused).toBeUndefined() + expect(asked.input).toContain(`--resume '${C}'`) + expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled() + } + ) + + posixOnlyIt('leaves an explicit resume of a chat at rest alone', async () => { + register([chat.sessionId]) + const asked = await spawnResume() + expect(asked.refused).toBeUndefined() + expect(asked.input).toContain(`--resume '${C}'`) + }) +}) diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts new file mode 100644 index 00000000000..af11bc574e6 --- /dev/null +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -0,0 +1,332 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + normalizeAgentProviderSession, + agentProviderSessionsEqual, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { + AgentStatusIpcPayload, + RefusedPaneResume +} from '../../../../shared/agent-status-types' +import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import { + agentSessionLeasePossiblyWriting, + listStructuredProviderSessionOwnership +} from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' +import { agentHookServer } from '../../../agent-hooks/server' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { + keysMayMatch, + terminalConversationKey +} from '../../../native-chat/structured-conversation-key' + +export type ResumeSpawnArgs = { + connectionId?: string | null + command?: string + launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchToken?: unknown + resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: the resume was built from a pane's saved facts, not asked for by the user. */ + resumeOrigin?: unknown + env?: Record +} + +type AdmissionRuntime = { + getAgentProviderSessionRows?(): AgentStatusIpcPayload[] + readSavedResumeSource?( + worktreeId: string, + paneKey: string + ): Promise + openAgentSessionRecordStore(): Promise +} + +/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: + * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ +export type ResumeSpawnRefusal = { + sourceRecord?: SleepingAgentSessionRecord + verdict: 'chat-home' | 'chat-writing' | 'unverifiable' + refused: RefusedPaneResume +} + +export type ResumeSpawnAdmission = + | { verdict: 'admitted'; refused: RefusedPaneResume; sourceRecord?: SleepingAgentSessionRecord } + | ResumeSpawnRefusal + +/** Saved-home policy only; ordinary terminal starts remain hook-observed. */ +export async function admitResumeSpawn( + runtime: AdmissionRuntime | undefined, + args: ResumeSpawnArgs, + wslDistro: string | null +): Promise { + const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) + const agent = args.launchAgent + // Native chats run only natively, so a remote or WSL conversation is never a chat's. + if ( + args.resumeOrigin !== 'saved' || + !providerSession || + !agent || + args.connectionId || + wslDistro !== null + ) { + return null + } + if (!runtime) { + return { verdict: 'unverifiable', refused: { agent, providerSession } } + } + return judgeResumes(runtime, [{ agent, providerSession }]) +} + +/** A reattach that found its session gone returns a fresh shell its window then resumes from the + * pane's saved facts; this decides that resume before the window can deliver it. */ +export async function admitColdRestoreReattach( + runtime: + | (AdmissionRuntime & { getAgentProviderSessionRows(): AgentStatusIpcPayload[] }) + | undefined, + result: { coldRestore?: unknown; isReattach?: boolean }, + pane: { paneKey: string | null; saved: SleepingAgentSessionRecord | undefined }, + wslDistro: string | null +): Promise { + const { paneKey, saved } = pane + if (!runtime || !result.coldRestore || result.isReattach || !paneKey || wslDistro !== null) { + return null + } + const resumes = [ + ...runtime + .getAgentProviderSessionRows() + .flatMap((row) => + row.paneKey === paneKey && + row.state !== 'done' && + !row.structuredHost && + !row.connectionId && + row.agentType && + row.providerSession + ? [{ agent: row.agentType, providerSession: row.providerSession }] + : [] + ), + ...(saved && !saved.connectionId + ? [{ agent: saved.agent, providerSession: saved.providerSession }] + : []) + ] + const [first] = resumes + return first ? judgeResumes(runtime, [first]) : null +} + +async function judgeResumes( + runtime: AdmissionRuntime, + resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]] +): Promise { + const keysOf = ({ agent, providerSession }: RefusedPaneResume) => + [providerSession.id, providerSession.transcriptPath].flatMap((id) => + id?.trim() ? [terminalConversationKey(agent, id)] : [] + ) + const admitted = (): ResumeSpawnAdmission => ({ + verdict: 'admitted', + refused: resumes[0] + }) + let store: AgentSessionRecordStore + try { + store = await runtime.openAgentSessionRecordStore() + } catch (error) { + console.warn( + "[pty] native chat records unreadable; cannot tell whether a resume is a chat's", + error + ) + return { verdict: 'unverifiable', refused: resumes[0] } + } + const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( + (holding) => holding.role === 'head' + ) + const tabs = store.getVisibleSessionTabIndex() + for (const refused of resumes) { + const holders = heads.filter((holding) => + keysOf(refused).some((key) => keysMayMatch(holding.key, key)) + ) + if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { + return { verdict: 'chat-writing', refused } + } + if (holders.length > 0 && (!tabs || !tabs.present)) { + return { verdict: 'unverifiable', refused } + } + if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) { + return { verdict: 'chat-home', refused } + } + } + return admitted() +} + +/** A refused resume becomes the pane's plain shell: nothing of the agent launch is left to run, + * register, or mark the shell as an unattended agent's. */ +export function dropAgentLaunch(args: ResumeSpawnArgs): void { + const agentEnv = args.launchConfig?.agentEnv ?? {} + if (args.env) { + args.env = Object.fromEntries( + Object.entries(args.env).filter( + ([key, value]) => + key !== 'ORCA_AGENT_LAUNCH_TOKEN' && + key !== 'ORCA_SEQUENCED_STARTUP_COMMAND' && + key !== 'ORCA_SEQUENCED_STARTUP_SCRIPT' && + agentEnv[key] !== value + ) + ) + } + delete args.command + delete args.launchAgent + delete args.launchConfig + delete args.launchToken + delete args.resumeProviderSession +} + +export function resumeSpawnRefusal( + admission: ResumeSpawnAdmission | null +): ResumeSpawnRefusal | null { + return admission && admission.verdict !== 'admitted' ? admission : null +} + +/** Unwinds only the claim whose fresh callback has issued no physical work. */ +export class SavedResumeFreshRefusal extends Error { + constructor(readonly admission: ResumeSpawnRefusal) { + super('saved_resume_fresh_refused') + } +} + +/** Called only after a refused plain shell exists; uncertain evidence retains its recipe. */ +export function forgetRefusedResume( + paneKey: string | null, + refusal: ResumeSpawnRefusal | null +): void { + if (!paneKey || !refusal || refusal.verdict === 'unverifiable') { + return + } + try { + agentHookServer.forgetRefusedPaneResume(paneKey, refusal.refused) + } catch (error) { + // The shell still starts, and the next restore refuses and retries the cleanup. + console.warn('[pty] could not forget a resume a native chat holds', error) + } +} + +/** The pane's saved resume on this host, which its window resumes from after a cold restore. */ +export function savedResumeRecord( + store: + | { getWorkspaceSession?(hostId: ExecutionHostId): WorkspaceSessionState | undefined } + | undefined, + paneKey: string | null +): SleepingAgentSessionRecord | undefined { + return paneKey + ? store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.sleepingAgentSessionsByPaneKey?.[ + paneKey + ] + : undefined +} + +/** Runs only in the fresh branch, after stable/claimed owner adoption has settled. */ +export async function chooseSavedResumeFreshLaunch( + runtime: AdmissionRuntime | undefined, + args: ResumeSpawnArgs, + wslDistro: string | null, + options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } +): Promise { + const admission = await admitResumeSpawn(runtime, args, wslDistro) + if (resumeSpawnRefusal(admission)) { + suppressSavedResumeLaunch(args, options) + } + return admission +} + +/** A claim joiner uses the same suppression without reclassifying the settled request. */ +export function suppressSavedResumeLaunch( + args: ResumeSpawnArgs, + options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } +): void { + options.launchConfig = args.launchConfig + dropAgentLaunch(options) + delete options.startupCommandDelivery + delete options.startupIngress + dropAgentLaunch(args) +} + +export function savedResumeResult(admission: ResumeSpawnAdmission | null) { + if (!admission) { + return {} + } + const { agent, providerSession } = admission.refused + return { + savedResumeDecision: { agent, providerSession, verdict: admission.verdict }, + ...(admission.verdict !== 'admitted' + ? { + agentResumeRefused: admission.verdict, + agentResumeUnavailable: true as const, + refusedResume: admission.refused + } + : {}) + } +} + +/** Capture before spawn; later same-conversation status and saved versions belong to later work. */ +export async function captureSavedResumeCleanup( + runtime: AdmissionRuntime | undefined, + store: Parameters[0], + paneKey: string | null, + admission: ResumeSpawnAdmission | null, + worktreeId?: string +): Promise { + if (!paneKey || !admission) { + return + } + let candidate = savedResumeRecord(store, paneKey) + if (runtime?.readSavedResumeSource && worktreeId) { + try { + candidate = await runtime.readSavedResumeSource(worktreeId, paneKey) + } catch (error) { + console.warn('[pty] could not capture saved resume cleanup source', error) + } + } + const saved = + candidate && + candidate.agent === admission.refused.agent && + agentProviderSessionsEqual( + candidate.agent, + candidate.providerSession, + admission.refused.providerSession + ) + ? candidate + : undefined + const row = runtime + ?.getAgentProviderSessionRows?.() + .find( + (entry) => + entry.paneKey === paneKey && + entry.agentType === admission.refused.agent && + agentProviderSessionsEqual( + entry.agentType, + entry.providerSession, + admission.refused.providerSession + ) + ) + admission.sourceRecord = saved + admission.refused = { + ...admission.refused, + ...(row + ? { + expectedStatus: { + paneKey, + receivedAt: row.receivedAt, + stateStartedAt: row.stateStartedAt + } + } + : {}), + ...(saved + ? { + expectedSavedRecord: { + paneKey, + capturedAt: saved.capturedAt, + updatedAt: saved.updatedAt + } + } + : {}) + } +} diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 2a2ab7dd35a..958d637b8c1 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -5,7 +5,6 @@ import { resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launc import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' import { agentLaunchPaneLiveness } from '../pane/agent-launch-pane-evidence' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import { createPtySpawnTiming } from '../pty-spawn-timing' import { allocatePtyLifecycleSequence } from '../host-env/types' @@ -13,7 +12,8 @@ import { snapshotCodexPaneHomeRoutes } from '../host-env/codex-home' import { getAppPtyId } from '../provider/registry' import { makePaneSpawnReservationKey, - pendingRuntimePaneCreatesByOwnerKey + pendingRuntimePaneCreatesByOwnerKey, + type PaneSpawnReservationResult } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' @@ -38,7 +38,7 @@ export function resolveEarlyPaneSpawnReservationKey(args: PtySpawnIpcArgs): stri export async function beginPtyIpcSpawn( ctx: PtyIpcSpawnState -): Promise { +): Promise { const args = ctx.args // Launch panes adopt their agent instead of starting a shell; explicit restarts already own the reservation. const launchPaneKey = ctx.paneSpawnReservation ? null : resolveEarlyPaneKey(args) @@ -147,6 +147,4 @@ export async function beginPtyIpcSpawn( // Bytes arriving during spawn can postdate proven flags; do not let old flags erase their live negotiation. ctx.snapshotKittyFlagsCoverReconciledSeq = true ctx.preparedProvisionalExecutionContext = false - - return null } diff --git a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts index 8652384bf59..3463c55414a 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts @@ -56,11 +56,15 @@ async function commit( ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } prepare?.(ctx) + let reply: Awaited> | undefined const outcome = await commitPtyIpcSpawn(ctx).then( - () => 'committed', + (result) => { + reply = result + return 'committed' + }, () => 'discarded' ) - return { outcome, facts, stops } + return { outcome, facts, stops, reply } } describe('renderer spawn commit: run facts', () => { @@ -78,6 +82,36 @@ describe('renderer spawn commit: run facts', () => { expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) }) + it.each([false, true])( + 'keeps a refused shell disposition when early exit is %s', + async (earlyExit) => { + const refused = { + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'chat-held' } + } + const { outcome, reply } = await commit(vi.fn().mockResolvedValue(true), (ctx) => { + ctx.resumeAdmission = { verdict: 'chat-home', refused } + if (earlyExit) { + ctx.metadataLeafId = 'leaf-1' + ctx.deps.runtime!.getPtyLivenessVerdict = vi.fn(() => ({ status: 'exited' as const })) + ctx.deps.store!.runDurableMutation = vi.fn().mockResolvedValue(true) + ctx.deps.runtime!.registerPty = vi.fn(() => { + throw new Error('agent_session_exited_during_start') + }) + } + }) + expect(outcome).toBe('committed') + expect(reply).toMatchObject({ + id: PTY_ID, + incarnationId: INCARNATION_ID, + agentResumeUnavailable: true, + agentResumeRefused: 'chat-home', + refusedResume: refused, + savedResumeDecision: { ...refused, verdict: 'chat-home' } + }) + } + ) + it('records nothing for a spawn discarded because its binding save failed', async () => { const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) const { outcome, facts, stops } = await commit(persistPtyBinding) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 9dbdf8a69d2..929a2aaf147 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -7,6 +7,7 @@ import type { PtySpawnPaneBinding } from '../spawn-commit-binding' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' +import { resumeSpawnRefusal, savedResumeResult } from '../host-env/saved-resume-admission' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { return commitPtyWithOpenCodePromptIntent(ctx, () => { @@ -97,11 +98,22 @@ function ipcSpawnPaneBinding(ctx: PtyIpcSpawnState): PtySpawnPaneBinding | undef } function ipcSpawnResponse(ctx: PtyIpcSpawnState) { + const args = ctx.args const { startupCwdSource: _startupCwdSource, ...providerResult } = ctx.result const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result) return { ...providerResult, - // Kitty flags and their proven sequence boundary must be published together. + ...(!ctx.result.isReattach && !resumeSpawnRefusal(ctx.resumeAdmission) + ? { + launchAgent: args.launchAgent, + launchToken: typeof args.launchToken === 'string' ? args.launchToken : undefined, + resumeProviderSession: ctx.codexResumeLaunch.droppedResumeArgv + ? undefined + : args.resumeProviderSession + } + : {}), + // Why both or neither: a pane can only adopt proven kitty flags together + // with the sequence boundary they describe. ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && ctx.reconciledSnapshotSeq !== null && ctx.snapshotKittyFlagsCoverReconciledSeq @@ -111,9 +123,22 @@ function ipcSpawnResponse(ctx: PtyIpcSpawnState) { ? { launchConfig: ctx.effectiveLaunchConfig } : {}), ...(startupCwdFallback ? { startupCwdFallback } : {}), - // Only a fresh launch can report dropped resume; reattach never ran this command. - ...(ctx.codexResumeLaunch.notifyResumeUnavailable && !ctx.result.isReattach + ...resumeDisposition(ctx) + } +} + +/** What became of a requested resume, on every result the renderer receives. */ +function resumeDisposition(ctx: PtyIpcSpawnState) { + // Why: the pane asked to resume and got a fresh session instead; only the + // renderer can say so, and a reattach never ran this launch command. + if (ctx.result.isReattach) { + return {} + } + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + return { + ...(ctx.codexResumeLaunch.notifyResumeUnavailable || refusal ? { agentResumeUnavailable: true as const } - : {}) + : {}), + ...savedResumeResult(ctx.resumeAdmission) } } diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 6deaf690eed..c4c21359f31 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -32,6 +32,12 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis mark: (phase) => ctx.spawnTiming.mark(phase) }) ctx.codexResumeLaunch = hostEnv.codexResumeLaunch + if (hostEnv.codexResumeLaunch.droppedResumeArgv && ctx.effectiveLaunchConfig) { + ctx.effectiveLaunchConfig = { + ...ctx.effectiveLaunchConfig, + agentCommand: hostEnv.launchCommand ?? ctx.effectiveLaunchConfig.agentCommand + } + } ctx.launchCommand = hostEnv.launchCommand ctx.baseEnv = hostEnv.baseEnv ctx.env = hostEnv.env diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts index b3fbeb2f011..c3967b6f187 100644 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -1,3 +1,9 @@ +import { + captureSavedResumeCleanup, + chooseSavedResumeFreshLaunch, + type ResumeSpawnAdmission, + resumeSpawnRefusal +} from '../host-env/saved-resume-admission' import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate' import { resolveStablePaneOwner } from '../pane/stable-owner' @@ -14,6 +20,42 @@ import type { PtyIpcSpawnState } from './spawn-state' export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + const captured: ResumeSpawnAdmission | null = + args.launchAgent && args.resumeProviderSession + ? { + verdict: 'admitted', + refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession } + } + : null + if (args.resumeOrigin === 'saved') { + await captureSavedResumeCleanup( + ctx.deps.runtime, + ctx.deps.store, + ctx.stablePaneKey, + captured, + args.worktreeId + ) + } + const chooseFresh = async (): Promise => { + ctx.spawnOptions.env = { ...ctx.spawnOptions.env } + args.launchConfig = ctx.effectiveLaunchConfig + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( + ctx.deps.runtime, + args, + ctx.expectedWslDistro, + ctx.spawnOptions + ) + if (captured && ctx.resumeAdmission) { + ctx.resumeAdmission.refused = captured.refused + ctx.resumeAdmission.sourceRecord = captured.sourceRecord + } + if (resumeSpawnRefusal(ctx.resumeAdmission)) { + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + ctx.baseEnv = ctx.spawnOptions.env + ctx.spawnEnv = ctx.spawnOptions.env + } + } try { if (ctx.preAllocatedHandle) { ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) @@ -65,9 +107,19 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { if (!ctx.preAdoptedStablePane && ctx.paneAgentLaunch) { ctx.paneAgentLaunch.dispatched = true } - ctx.result = attachOnlyViewOf(args) - ? await attachViewWithoutOwner(ctx.provider, ctx.spawnOptions) - : await ctx.provider.spawn(ctx.spawnOptions) + if (attachOnlyViewOf(args)) { + ctx.result = await attachViewWithoutOwner(ctx.provider, ctx.spawnOptions) + } else { + await chooseFresh() + ctx.result = await ctx.provider.spawn(ctx.spawnOptions) + } + if (ctx.result.isReattach) { + ctx.resumeAdmission = null + Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id)) + } + if (resumeSpawnRefusal(ctx.resumeAdmission)) { + ctx.effectiveLaunchConfig = undefined + } ctx.stablePaneOwner = null ctx.rejectedRegistrationCandidate = ctx.result if (ctx.pendingRegistrationPtyId !== ctx.result.id) { diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index e654fcbb40c..e0c6011833f 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -28,6 +28,13 @@ import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-ma import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { refuseClosedPaneSpawn } from './closed-pane-spawn-refusal' +import { + captureSavedResumeCleanup, + admitColdRestoreReattach, + forgetRefusedResume, + resumeSpawnRefusal, + savedResumeRecord +} from '../host-env/saved-resume-admission' function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { if (!ctx.agentTeamsLeaderHandle) { @@ -81,11 +88,41 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } try { await executePtyIpcSpawn(ctx) + if (!ctx.resumeAdmission) { + ctx.resumeAdmission = await admitColdRestoreReattach( + deps.runtime, + ctx.result, + { paneKey: ctx.stablePaneKey, saved: savedResumeRecord(deps.store, ctx.stablePaneKey) }, + ctx.expectedWslDistro + ) + await captureSavedResumeCleanup( + deps.runtime, + deps.store, + ctx.stablePaneKey, + ctx.resumeAdmission, + args.worktreeId + ) + } const committed = await commitPtyIpcSpawn(ctx) + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + forgetRefusedResume(ctx.stablePaneKey, refusal) + if (args.worktreeId && ctx.stablePaneKey && refusal && refusal.verdict !== 'unverifiable') { + void deps.runtime + ?.forgetRefusedSavedResume?.( + args.worktreeId, + ctx.stablePaneKey, + refusal.refused, + refusal.sourceRecord + ) + .catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error)) + } endPaneAgentLaunch( ctx, ctx.exitedBeforeRegistration ? { exitedDuringStart: true } : { started: true } ) + if (!ctx.result.isReattach) { + deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) + } return committed } finally { if (ctx.pendingRegistrationPtyId) { @@ -126,7 +163,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg if (adopted) { releaseAbandonedAgentTeamsLeader(ctx) } - return adopted?.result ?? completed! + return adopted + ? { ...adopted.result, ...deps.runtime?.getTerminalLaunchResult?.(adopted.result.id) } + : completed! } return complete(operation) } @@ -178,7 +217,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, adopted.result.id, true) }) endPaneAgentLaunch(ctx, { started: true }) - return adopted.result + return { ...adopted.result, ...deps.runtime?.getTerminalLaunchResult?.(adopted.result.id) } } return freshResult! } catch (caught) { diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index d647bd172eb..7330cff6921 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -9,6 +9,7 @@ import type { import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { ResumeSpawnAdmission } from '../host-env/saved-resume-admission' import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' import type { PtySpawnTiming } from '../pty-spawn-timing' import { createPtySpawnTiming } from '../pty-spawn-timing' @@ -86,6 +87,8 @@ export type PtyIpcSpawnState = { nativeWindowsConptySpawn: boolean codexSelectionTarget: CodexAccountSelectionTarget codexResumeLaunch: CodexResumeLaunch + /** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */ + resumeAdmission: ResumeSpawnAdmission | null launchCommand: string | undefined env: Record | undefined selectedCodexHomePath: string | null @@ -164,6 +167,7 @@ export function createPtyIpcSpawnState( nativeWindowsConptySpawn: false, codexSelectionTarget: { runtime: 'host' }, codexResumeLaunch: noCodexResumeLaunch(undefined), + resumeAdmission: null, launchCommand: undefined, env: undefined, selectedCodexHomePath: null, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 747c71b947d..02b9a9b39b7 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -33,6 +33,8 @@ export type PtySpawnIpcArgs = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + // Untyped on purpose: only `'saved'` marks a resume built from saved facts. + resumeOrigin?: unknown launchToken?: unknown launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index ce223be0e24..d3392389c76 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -4,7 +4,7 @@ import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints' import type { Store } from '../../../persistence' import type { IPtyProvider } from '../../../providers/pty-provider-contract' import { isCurrentPtyExit, ptyIncarnationById, ptyOwnership } from './ownership-state' -import { getRelayPtyId } from './registry' +import { getRelayPtyId, tryGetProviderForPty } from './registry' import { KEEP_HISTORY_STOP_POLL_MS, KEEP_HISTORY_STOP_SETTLE_MS @@ -127,3 +127,14 @@ export function finishPtyShutdown( ptyOwnership.delete(id) return incarnationId } + +export async function isRegisteredAgentSessionOwnerLive( + owner: AgentSessionOwnerBinding +): Promise { + const ownerProvider = tryGetProviderForPty(owner.ptyId) + if (!ownerProvider) { + // Why: an owner on a host this process cannot reach is unknown, never absent. + throw new Error('execution_owner_unavailable') + } + return await isProviderAgentSessionOwnerLive(ownerProvider, owner) +} diff --git a/src/main/ipc/pty/pty-codex-account-attribution.test.ts b/src/main/ipc/pty/pty-codex-account-attribution.test.ts index 82f827b90ac..f6c0d5a401b 100644 --- a/src/main/ipc/pty/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty/pty-codex-account-attribution.test.ts @@ -3,7 +3,8 @@ import { join } from 'node:path' import { readFileSyncMock, recordCodexPaneAccountMock, - forgetCodexPaneAccountMock + forgetCodexPaneAccountMock, + noChatsRuntimeMembers } from '../pty-ipc-mock-registry' import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from '../pty-ipc-test-constants' import { setupPtyIpcSuite } from '../pty-ipc-test-harness' @@ -287,6 +288,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -357,6 +359,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts index 49da0a0c77d..4009fbb970b 100644 --- a/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { piBuildPtyEnvMock } from '../pty-ipc-mock-registry' +import { piBuildPtyEnvMock, noChatsRuntimeMembers } from '../pty-ipc-mock-registry' import { setupPtyIpcSuite } from '../pty-ipc-test-harness' import { type DaemonSpawnCall, @@ -127,6 +127,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -512,6 +513,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -546,6 +548,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -578,6 +581,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty/pty-pane-materialization-race.test.ts b/src/main/ipc/pty/pty-pane-materialization-race.test.ts index 3e59a0c6715..f15938f805f 100644 --- a/src/main/ipc/pty/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty/pty-pane-materialization-race.test.ts @@ -389,6 +389,12 @@ describe('registerPtyHandlers', () => { expect(rendererResult).toEqual({ id: 'pty-renderer' }) expect(runtimeResult).toEqual({ id: 'pty-renderer', + isReattach: true, + launchAgent: undefined, + launchToken: undefined, + resumeProviderSession: undefined, + snapshotKittyKeyboardFlags: undefined, + wslDistro: undefined, stablePaneOwner: { handle: 'term_trusted', tabId: 'tab-race', diff --git a/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts b/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts index ce1b52c56cb..49a58556ead 100644 --- a/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts +++ b/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { readFileSyncMock } from '../pty-ipc-mock-registry' +import { readFileSyncMock, noChatsRuntimeMembers } from '../pty-ipc-mock-registry' import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from '../pty-ipc-test-constants' import { setupPtyIpcSuite } from '../pty-ipc-test-harness' import { createDaemonActiveProviderFixtures } from '../pty-ipc-daemon-provider-fixtures' @@ -80,6 +80,7 @@ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home function makeRuntime() { return { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), createPreAllocatedTerminalHandle: vi.fn(() => null), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), diff --git a/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts index d5d3638de2e..2aa3a7fca83 100644 --- a/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { join } from 'node:path' -import { spawnMock } from '../pty-ipc-mock-registry' +import { spawnMock, noChatsRuntimeMembers } from '../pty-ipc-mock-registry' import { posixOnlyIt, TEST_MANAGED_ROOT } from '../pty-ipc-test-constants' import { setupPtyIpcSuite } from '../pty-ipc-test-harness' import { prepareCodexSessionResume } from '../../codex/codex-session-resume-preparation' @@ -347,6 +347,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupResumeDaemonProvider() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts index d45811bb8bc..39a394cb546 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts @@ -47,12 +47,22 @@ describe('runtime spawn commit: adopted agent-session claim', () => { it('commits the live grid from the adoption reply before the early return', async () => { const { runtime, ctx } = makeAdoptedCtx({ isReattach: true, + launchAgent: 'codex', + launchToken: 'owner-token', snapshotCols: LIVE_GRID.cols, snapshotRows: LIVE_GRID.rows }) - await commitRuntimePtySpawn(ctx) + const reply = await commitRuntimePtySpawn(ctx) + expect(reply).toMatchObject({ + id: PTY_ID, + isReattach: true, + launchAgent: 'codex', + launchToken: 'owner-token', + snapshotCols: LIVE_GRID.cols, + snapshotRows: LIVE_GRID.rows + }) expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID) expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith( PTY_ID, diff --git a/src/main/ipc/pty/runtime/spawn-commit-response.test.ts b/src/main/ipc/pty/runtime/spawn-commit-response.test.ts index 2db2240f10e..c3935d76ea2 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-response.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-response.test.ts @@ -4,9 +4,22 @@ import { reportRuntimeResumeOutcome } from './spawn-commit-response' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' import type { PtyRuntimeControllerDeps } from './controller-deps' -type Facts = { resumed?: boolean; unavailable?: boolean; dropped?: boolean; isReattach?: boolean } +type Facts = { + resumed?: boolean + unavailable?: boolean + dropped?: boolean + isReattach?: boolean + refused?: boolean +} -function reply({ resumed = false, unavailable = false, dropped = false, isReattach }: Facts) { +/** What a committed spawn tells its start, from a start that did or did not ask to resume. */ +function reply({ + resumed = false, + unavailable = false, + dropped = false, + isReattach, + refused +}: Facts) { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the report reads no deps. const deps = {} as unknown as PtyRuntimeControllerDeps const onResumeOutcome = vi.fn() @@ -18,7 +31,13 @@ function reply({ resumed = false, unavailable = false, dropped = false, isReatta ...(resumed ? { resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } } : {}) } as unknown as RuntimePtySpawnArgs const ctx = createRuntimePtySpawnState(deps, args) - ctx.result = { id: 'pty-1', ...(isReattach ? { isReattach } : {}) } + ctx.result = { + id: 'pty-1', + ...(isReattach ? { isReattach } : {}), + ...(refused + ? { agentResumeRefused: 'chat-home' as const, agentResumeUnavailable: true as const } + : {}) + } ctx.codexResumeUnavailable = unavailable ctx.codexResumeArgvDropped = dropped || unavailable reportRuntimeResumeOutcome(ctx) @@ -39,9 +58,17 @@ describe("a runtime spawn's resume outcome", () => { expect(reply({ resumed: true, unavailable: true }).resumeOutcome).toBe(on('resume-unavailable')) }) + it('reports the effective refused shell after admission removed its requested resume', () => { + expect(reply({ refused: true })).toEqual({ + resumeOutcome: on('resume-unavailable'), + ptyId: on('pty-1') + }) + }) + it('says nothing for a plain start, a silently dropped resume, or a reattach', () => { expect(reply({}).resumeOutcome).toBeUndefined() expect(reply({ resumed: true, dropped: true }).resumeOutcome).toBeUndefined() expect(reply({ resumed: true, isReattach: true }).resumeOutcome).toBeUndefined() + expect(reply({ refused: true, isReattach: true }).resumeOutcome).toBeUndefined() }) }) diff --git a/src/main/ipc/pty/runtime/spawn-commit-response.ts b/src/main/ipc/pty/runtime/spawn-commit-response.ts index 57f59601291..7831b1c3094 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-response.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-response.ts @@ -10,14 +10,15 @@ type RuntimeResumeOutcomeState = Pick< /** Whether a fresh start resumed the agent it was asked to; a reattach ran no launch command. */ export function resumeOutcomeOf(ctx: RuntimeResumeOutcomeState): PaneResumeOutcome | null { - if (!ctx.args.resumeProviderSession || ctx.result.isReattach) { + if (ctx.result.isReattach) { return null } - if (ctx.codexResumeUnavailable) { + // Refused saved admission removes the request; the spawned shell still carries its outcome. + if (ctx.result.agentResumeUnavailable || ctx.codexResumeUnavailable) { return 'resume-unavailable' } // A resume dropped silently named another agent's session: nothing came back, and nothing is owed. - return ctx.codexResumeArgvDropped ? null : 'restored' + return ctx.args.resumeProviderSession && !ctx.codexResumeArgvDropped ? 'restored' : null } /** Publish resume outcome before binding so the first frame with the new terminal includes it. */ diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts index 594bf63a5ff..e18292ef4ae 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -112,33 +112,39 @@ describe('runtime spawn commit: run facts', () => { expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) }) - it('reports the resumed agent before the bind can publish its first frame', async () => { - const onResumeOutcome = vi.fn() - const persistPtyBinding = vi.fn(async () => { - expect(onResumeOutcome.mock.calls).toEqual( - ORCA_LAYOUT_OWNER_IS_MAIN ? [[PTY_ID, 'resume-unavailable']] : [] - ) - return true - }) + it.each([false, true])( + 'reports unavailable before binding even when the request was erased (%s)', + async (erased) => { + const onResumeOutcome = vi.fn() + const persistPtyBinding = vi.fn(async () => { + expect(onResumeOutcome.mock.calls).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [[PTY_ID, 'resume-unavailable']] : [] + ) + return true + }) - await commit({}, undefined, undefined, (ctx) => { - ctx.args.resumeProviderSession = { key: 'session_id', id: 'codex-session-1' } - ctx.args.onResumeOutcome = onResumeOutcome - ctx.codexResumeUnavailable = true - ctx.codexResumeArgvDropped = true - ctx.hostSessionBinding = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this boundary pin observes the bind write without invoking its stored callback. - store: { persistPtyBinding } as unknown as NonNullable< - typeof ctx.hostSessionBinding - >['store'], - worktreeId: 'wt-1', - tabId: 'tab-1', - leafId: 'leaf-1' - } - }) + await commit({}, undefined, undefined, (ctx) => { + ctx.args.resumeProviderSession = erased + ? undefined + : { key: 'session_id', id: 'codex-session-1' } + ctx.result.agentResumeUnavailable = erased ? true : undefined + ctx.args.onResumeOutcome = onResumeOutcome + ctx.codexResumeUnavailable = !erased + ctx.codexResumeArgvDropped = true + ctx.hostSessionBinding = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this boundary pin observes the bind write without invoking its stored callback. + store: { persistPtyBinding } as unknown as NonNullable< + typeof ctx.hostSessionBinding + >['store'], + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1' + } + }) - expect(persistPtyBinding).toHaveBeenCalledOnce() - }) + expect(persistPtyBinding).toHaveBeenCalledOnce() + } + ) it('records nothing for a spawn discarded because its binding save failed', async () => { const facts = new TerminalRunFactsRegister() diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index fc5abb29b66..e84ed72d37e 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -42,6 +42,8 @@ export function adoptMaterializedRuntimePtySpawn( throw new Error('terminal_pane_owner_unknown') } ctx.result = { + ...ctx.preAdoptedStablePane.result, + isReattach: true, id: ctx.preAdoptedStablePane.result.id, ...(ctx.preAdoptedStablePane.result.incarnationId ? { incarnationId: ctx.preAdoptedStablePane.result.incarnationId } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 107a8d7a647..98d78632fed 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -1,7 +1,15 @@ +import { + suppressSavedResumeLaunch, + captureSavedResumeCleanup, + chooseSavedResumeFreshLaunch, + type ResumeSpawnAdmission, + resumeSpawnRefusal, + SavedResumeFreshRefusal +} from '../host-env/saved-resume-admission' +import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { ptyIncarnationById } from '../provider/ownership-state' -import { tryGetProviderForPty } from '../provider/registry' import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate' import { @@ -15,13 +23,46 @@ import { excludeAbsentOwnerReplay, PaneOwnerContentionError } from '../pane/pane-spawn-operation' -import { isProviderAgentSessionOwnerLive } from '../provider/liveness' +import { isRegisteredAgentSessionOwnerLive } from '../provider/liveness' import { failPtySpawnExecute } from '../spawn-execute-failure' import type { RuntimePtySpawnState } from './spawn-state' import { markRuntimeSpawnHiddenBeforeSpawn } from './spawn-hidden-delivery' export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { const args = ctx.args + const captured: ResumeSpawnAdmission | null = + args.launchAgent && args.resumeProviderSession + ? { + verdict: 'admitted', + refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession } + } + : null + if (args.resumeOrigin === 'saved') { + await captureSavedResumeCleanup( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + captured, + args.worktreeId + ) + } + const chooseFresh = async (): Promise => { + ctx.spawnOptions.env = { ...ctx.spawnOptions.env } + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( + ctx.deps.runtime, + args, + ctx.expectedWslDistro, + ctx.spawnOptions + ) + if (captured && ctx.resumeAdmission) { + ctx.resumeAdmission.refused = captured.refused + ctx.resumeAdmission.sourceRecord = captured.sourceRecord + } + if (resumeSpawnRefusal(ctx.resumeAdmission)) { + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + } + } const runtime = ctx.deps.runtime const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn ctx.releaseWorktreeSpawn = acquireWorktreeSpawn @@ -111,62 +152,98 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ctx.pendingRegistrationPtyId = recoveredOwner.ptyId } let providerResult: PtySpawnResult | null = null - const ensured = await agentSessionOwners.ensure({ - claim: args.agentSessionEnsure.claim, - surface: args.agentSessionEnsure.surface, - spawn: async () => { - assertClientStillConnected() - providerResult = await ctx.provider.spawn(ctx.spawnOptions) - ctx.rejectedRegistrationCandidate = providerResult - // Lower-owner success proves physical work committed even if admission observes early exit. - ctx.reportPtySpawnCommitted() - assertSpawnReplyWasLive(providerResult) - ctx.deps.runtime?.assertPtyRegistrationAllowed?.( - providerResult.id, - providerResult.incarnationId - ) - if (providerResult.incarnationId) { - // In-process providers cannot list controller claims; record exact incarnation before ownership promotion. - ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + let ensured: AgentSessionClaimedSpawnResult | undefined + try { + ensured = await agentSessionOwners.ensure({ + claim: args.agentSessionEnsure.claim, + surface: args.agentSessionEnsure.surface, + spawn: async () => { + assertClientStillConnected() + await chooseFresh() + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + if (refusal) { + throw new SavedResumeFreshRefusal(refusal) + } + providerResult = await ctx.provider.spawn(ctx.spawnOptions) + ctx.rejectedRegistrationCandidate = providerResult + // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. + ctx.reportPtySpawnCommitted() + assertSpawnReplyWasLive(providerResult) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.( + providerResult.id, + providerResult.incarnationId + ) + if (providerResult.incarnationId) { + // Why: local providers cannot serialize controller claims, so liveness proof + // needs the exact incarnation before the registry promotes the new owner. + ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + } + const providerEnsure = providerResult.agentSessionEnsure + return { + ptyId: providerResult.id, + ...(providerEnsure + ? { + owner: providerEnsure.owner, + disposition: providerEnsure.disposition + } + : {}) + } + }, + isLive: isRegisteredAgentSessionOwnerLive, + onJoin: () => { + awaitingAnotherStart = true } - const providerEnsure = providerResult.agentSessionEnsure - return { - ptyId: providerResult.id, - ...(providerEnsure - ? { - owner: providerEnsure.owner, - disposition: providerEnsure.disposition - } - : {}) - } - }, - isLive: async (owner) => { - const ownerProvider = tryGetProviderForPty(owner.ptyId) - if (!ownerProvider) { - // Unreachable execution owners remain unknown. - throw new Error('execution_owner_unavailable') - } - return await isProviderAgentSessionOwnerLive(ownerProvider, owner) - }, - onJoin: () => { - awaitingAnotherStart = true + }) + } catch (error) { + if (!(error instanceof SavedResumeFreshRefusal)) { + throw error } - }) + // The claim reservation rejected before spawn; this shell must never become its owner. + ctx.resumeAdmission = { + ...error.admission, + refused: captured?.refused ?? error.admission.refused, + sourceRecord: captured?.sourceRecord + } + suppressSavedResumeLaunch(args, ctx.spawnOptions) + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + delete args.agentSessionEnsure + ctx.spawnOptions.agentSessionEnsure = undefined + providerResult = await ctx.provider.spawn(ctx.spawnOptions) + ctx.reportPtySpawnCommitted() + ctx.stablePaneOwner = null + } awaitingAnotherStart = false - ctx.result = providerResult ?? { - id: ensured.owner.ptyId, - isReattach: true, - // Preserve listing-proven incarnation to reject delayed exits from older processes. - incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + if (providerResult) { + ctx.result = providerResult + } else if (ensured) { + ctx.result = { + id: ensured.owner.ptyId, + isReattach: true, + // A listing's incarnation rejects delayed exits from an older process. + incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + } + } else { + throw new Error('agent_session_owner_missing') + } + if (ensured) { + ctx.result.agentSessionEnsure = ensured } - ctx.result.agentSessionEnsure = ensured } else { assertClientStillConnected() + await chooseFresh() ctx.result = await ctx.provider.spawn(ctx.spawnOptions) ctx.reportPtySpawnCommitted() ctx.stablePaneOwner = null ctx.rejectedRegistrationCandidate = ctx.result } + if (ctx.result.isReattach || ctx.result.agentSessionEnsure?.disposition === 'adopted') { + ctx.result.isReattach = true + ctx.resumeAdmission = null + } + if (ctx.result.isReattach) { + Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id)) + } ctx.rejectedRegistrationCandidate ??= ctx.result if (ctx.pendingRegistrationPtyId !== ctx.result.id) { if (ctx.pendingRegistrationPtyId) { diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts new file mode 100644 index 00000000000..afe5f1ad48f --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -0,0 +1,416 @@ +import { CLAUDE_CLI_CONVERSATIONS } from '../../../claude/claude-cli-conversations' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { localProvider } from '../provider/registry' +import { spawnPtyFromRuntimeController } from './spawn' +import * as owners from '../pane/agent-session-owners' +import { describe, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import { getDefaultSettings } from '../../../../shared/constants' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { finishPtyShutdown } from '../provider/liveness' +import { listStructuredProviderSessionOwnership } from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { executeRuntimePtySpawn } from './spawn-execute' +import { + admitColdRestoreReattach, + admitResumeSpawn, + savedResumeResult +} from '../host-env/saved-resume-admission' +import { createRuntimePtySpawnState } from './spawn-state' + +const forgetRefusedPaneResume = vi.hoisted(() => vi.fn()) +vi.mock('../../../agent-hooks/server', () => ({ agentHookServer: { forgetRefusedPaneResume } })) + +vi.mock('./spawn-preflight', () => ({ prepareRuntimePtySpawn: vi.fn(async () => null) })) +vi.mock('./spawn-options', () => ({ + buildRuntimePtySpawnOptions: vi.fn(async (ctx: ReturnType) => { + ctx.spawnIdentityPaneKey = 'tab-1:leaf' + ctx.spawnOptions = { + cols: ctx.args.cols, + rows: ctx.args.rows, + command: ctx.args.command, + env: ctx.args.env, + launchAgent: ctx.args.launchAgent + } + return null + }) +})) + +const C = 'provider-session-alpha-1' +const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) +) + +const store = { + listRecords: () => [chat], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [chat.sessionId] }) +} + +/** A host whose one chat continues C at rest, with its tab open. */ +function chatHost() { + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the two members `store` has. + openAgentSessionRecordStore: async () => store as unknown as AgentSessionRecordStore, + getTerminalLaunchResult: vi.fn(), + forgetRefusedSavedResume: vi.fn(async () => {}), + getAgentProviderSessionRows: () => [ + { + paneKey: 'tab-1:leaf', + connectionId: null, + agentType: 'claude' as const, + state: 'working' as const, + prompt: '', + receivedAt: 1, + stateStartedAt: 1, + providerSession: { key: 'session_id' as const, id: C } + } + ] + } +} + +function deps(runtime: ReturnType): PtyRuntimeControllerDeps { + const noCodexResumeLaunch: PtyRuntimeControllerDeps['noCodexResumeLaunch'] = (command) => ({ + codexResumeHome: null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession: null + }) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preflight reads only the admission members `chatHost` provides. + runtime: runtime as unknown as OrcaRuntimeService, + store: undefined, + getSettings: () => getDefaultSettings('/tmp'), + adoptStablePane: async () => null, + getLocalPtyStartupPromise: () => undefined, + getLocalPtyProviderStartupPromise: () => undefined, + prepareCodexResumeHome: () => null, + resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command), + noCodexResumeLaunch, + reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath, + stripSequencedStartupResumeArgv: (env) => env, + assertWorkspaceAcceptsNewPty: () => undefined, + resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd, + requestSerializedBuffer: async () => null, + shutdownProviderAndDetectExit: async () => false, + rememberSyntheticKillExit: () => {}, + rememberRetiredRejectedPty: () => {}, + sendPtyExitToRenderer: () => {}, + sendPtySpawnedToRenderer: () => {}, + finishPtyShutdown, + trustedTerminalHandleEnv: new Set(), + retiredRejectedPtyIds: new Map(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the spawn preflight never touches `mainWindow`. + mainWindow: {} as BrowserWindow + } +} + +describe('actual runtime saved-resume outcomes', () => { + function freshContext(runtime = chatHost()) { + const ctx = createRuntimePtySpawnState(deps(runtime), { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + command: `claude --resume ${C}`, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: C }, + resumeOrigin: 'saved', + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: { AGENT_ONLY: 'yes' } }, + env: { + WORKSPACE: 'keep', + AGENT_ONLY: 'yes', + ORCA_AGENT_LAUNCH_TOKEN: 'rejected', + ORCA_SEQUENCED_STARTUP_COMMAND: `claude --resume ${C}`, + ORCA_SEQUENCED_STARTUP_SCRIPT: `fallback claude --resume ${C}` + } + }) + ctx.spawnOptions = { + cols: 80, + rows: 24, + command: ctx.args.command, + launchAgent: 'claude', + env: { ...ctx.args.env } + } + return ctx + } + + it.each([10, 100])( + 'counts real saved-spawn projection and freshness across %i chats', + async (size) => { + const records = Array.from({ length: size }, (_, index) => ({ + ...chat, + sessionId: `chat-${index}`, + providerHandleChain: chat.providerHandleChain.map((link) => ({ + ...link, + handle: { ...link.handle, nativeId: `unrelated-${index}` } + })) + })) + const list = vi.spyOn(store, 'listRecords').mockImplementation(() => records) + const visible = vi.spyOn(store, 'getVisibleSessionTabIndex').mockReturnValue({ + present: true, + sessionIds: records.map((record) => record.sessionId) + }) + const read = vi.spyOn(CLAUDE_CLI_CONVERSATIONS.sessionHistory, 'rowSessionId') + const runtime = chatHost() + let rowReads = 0 + const originalRow = runtime.getAgentProviderSessionRows()[0]! + const rows = Array.from({ length: size }, (_, index) => ({ + ...originalRow, + get paneKey() { + rowReads += 1 + return index === size - 1 ? 'tab-1:leaf' : `unrelated-pane-${index}` + } + })) + runtime.getAgentProviderSessionRows = () => rows + const run = async (ordinary = false) => { + const ctx = freshContext(runtime) + ctx.spawnIdentityPaneKey = 'tab-1:leaf' + if (ordinary) { + delete ctx.args.resumeOrigin + } + await executeRuntimePtySpawn(ctx) + return ctx + } + const spawn = vi.spyOn(localProvider, 'spawn').mockResolvedValue({ id: 'counted-shell' }) + try { + await admitColdRestoreReattach( + runtime, + { coldRestore: {}, isReattach: true }, + { paneKey: 'tab-1:leaf', saved: undefined }, + null + ) + await run(true) + expect([ + list.mock.calls.length, + visible.mock.calls.length, + read.mock.calls.length, + rowReads + ]).toEqual([0, 0, 0, 0]) + for (let pane = 0; pane < size; pane += 1) { + expect((await run()).resumeAdmission?.verdict).toBe('admitted') + if (pane === 0) { + expect([read.mock.calls.length, rowReads]).toEqual([3 * size, size]) + } + } + // Each pane projects N heads and two handle reads per link; P=N exposes the aggregate cost. + expect(read.mock.calls.length).toBe(size * 3 * size) + expect([list.mock.calls.length, visible.mock.calls.length, rowReads]).toEqual([ + size, + size, + size * size + ]) + const target = records[0]! + target.providerHandleChain[0]!.handle.nativeId = C + const refused = await run() + expect(refused.resumeAdmission?.verdict).toBe('chat-home') + expect(refused.spawnOptions.command).toBeUndefined() + target.providerHandleChain[0]!.handle.nativeId = 'successor-conversation' + expect((await run()).resumeAdmission?.verdict).toBe('admitted') + expect(read.mock.calls.length).toBe((size + 2) * 3 * size) + } finally { + spawn.mockRestore() + read.mockRestore() + list.mockRestore() + visible.mockRestore() + } + } + ) + + it('chooses a plain shell only in the fresh callback and does not forget before commit', async () => { + const runtime = chatHost() + const ctx = freshContext(runtime) + const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'plain-shell' }) + try { + expect(ctx.args.command).toContain(C) + await executeRuntimePtySpawn(ctx) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WORKSPACE: 'keep' } + }) + ) + expect(ctx.spawnOptions.command).toBeUndefined() + expect(ctx.args.launchAgent).toBeUndefined() + expect(savedResumeResult(ctx.resumeAdmission)).toMatchObject({ + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true, + savedResumeDecision: { verdict: 'chat-home', providerSession: { id: C } } + }) + expect(forgetRefusedPaneResume).not.toHaveBeenCalled() + expect(runtime.forgetRefusedSavedResume).not.toHaveBeenCalled() + } finally { + spawn.mockRestore() + } + }) + + it('lets the actual lower-owned live result override a tentative fresh refusal', async () => { + const runtime = chatHost() + const ctx = freshContext(runtime) + runtime.getTerminalLaunchResult.mockReturnValue({ launchAgent: 'codex' }) + const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ + id: 'already-live', + isReattach: true, + launchAgent: 'codex' + }) + try { + await executeRuntimePtySpawn(ctx) + expect(ctx.result).toMatchObject({ + id: 'already-live', + isReattach: true, + launchAgent: 'codex' + }) + expect(ctx.resumeAdmission).toBeNull() + expect(runtime.forgetRefusedSavedResume).not.toHaveBeenCalled() + } finally { + spawn.mockRestore() + } + }) + + it('checks the selected late cold-shell candidate but never checks a real reattach', async () => { + const runtime = chatHost() + const pane = { paneKey: 'tab-1:leaf', saved: undefined } + await expect( + admitColdRestoreReattach( + runtime, + { coldRestore: { scrollback: '', cwd: '/tmp' } }, + pane, + null + ) + ).resolves.toMatchObject({ verdict: 'chat-home', refused: { providerSession: { id: C } } }) + await expect( + admitColdRestoreReattach(runtime, { coldRestore: {}, isReattach: true }, pane, null) + ).resolves.toBeNull() + }) +}) + +it('unwinds a refused fresh claim before spawning an unclaimed shell', async () => { + const runtime = chatHost() + const ctx = createRuntimePtySpawnState(deps(runtime), { + cols: 80, + rows: 24, + launchAgent: 'claude', + command: `claude --resume ${C}`, + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: C }, + agentSessionEnsure: { + claim: { + agent: 'claude', + digestVersion: 1, + keyId: 'key', + identityDigest: 'C', + worktreeScopeDigest: 'scope' + }, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf', terminalHandle: 'terminal' } + } + }) + const claim = ctx.args.agentSessionEnsure?.claim + if (!claim) { + throw new Error('missing fixture claim') + } + ctx.spawnOptions = { + cols: 80, + rows: 24, + command: ctx.args.command, + agentSessionEnsure: ctx.args.agentSessionEnsure + } + const inventory = vi.spyOn(owners, 'reconcileAgentSessionOwnerListings').mockResolvedValue() + const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'refused-shell' }) + try { + await executeRuntimePtySpawn(ctx) + expect(spawn).toHaveBeenCalledOnce() + expect(ctx.spawnOptions.command).toBeUndefined() + expect(ctx.result.agentSessionEnsure).toBeUndefined() + expect(owners.agentSessionOwners.find(claim)).toBeNull() + expect(ctx.resumeAdmission).toMatchObject({ verdict: 'chat-home' }) + } finally { + inventory.mockRestore() + spawn.mockRestore() + } +}) + +it('returns the committed runtime shell when exact saved-record persistence fails', async () => { + const runtime = { + ...chatHost(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + forgetRefusedSavedResume: vi.fn(async () => { + throw new Error('saved record disk full') + }) + } + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const spawn = vi.spyOn(localProvider, 'spawn').mockResolvedValue({ id: 'cleanup-failed-shell' }) + const onResumeOutcome = vi.fn() + try { + const result = await spawnPtyFromRuntimeController(deps(runtime), { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + command: `claude --resume ${C}`, + launchAgent: 'claude', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: C }, + onResumeOutcome + }) + expect(result).toMatchObject({ id: 'cleanup-failed-shell', agentResumeRefused: 'chat-home' }) + expect(spawn.mock.calls[0]?.[0].command).toBeUndefined() + expect(runtime.registerPty).toHaveBeenCalled() + expect(runtime.forgetRefusedSavedResume).toHaveBeenCalledOnce() + expect(onResumeOutcome.mock.calls).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [['cleanup-failed-shell', 'resume-unavailable']] : [] + ) + await Promise.resolve() + expect(warning).toHaveBeenCalled() + } finally { + spawn.mockRestore() + warning.mockRestore() + } +}) + +it('preserves case-distinct conversation heads and saved homes across the shared OpenCode family', async () => { + const ids = ['ses_000000000001AAAAAAAAAAAAAA', 'ses_000000000001aaaaaaaaaaaaaa'] + const record = { + ...chat, + provider: 'opencode' as const, + providerHandleChain: ids.map((nativeId, index) => ({ + ...chat.providerHandleChain[0]!, + linkId: `case-${index}`, + handle: { transport: 'acp' as const, agent: 'opencode' as const, nativeId } + })) + } + const opencodeStore: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + opencodeStore.listRecords = () => [record] + opencodeStore.getVisibleSessionTabIndex = () => ({ + present: true, + sessionIds: [record.sessionId] + }) + const runtime = { openAgentSessionRecordStore: async () => opencodeStore } + expect(listStructuredProviderSessionOwnership([record]).map((holding) => holding.role)).toEqual([ + 'earlier', + 'head' + ]) + for (const [index, id] of ids.entries()) { + await expect( + admitResumeSpawn( + runtime, + { + launchAgent: 'opencode2', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id } + }, + null + ) + ).resolves.toMatchObject({ verdict: index === 0 ? 'admitted' : 'chat-home' }) + } +}) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 503614c2cb7..6b0b6a742bc 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -144,6 +144,13 @@ export async function prepareRuntimePtySpawn( provider: ctx.provider, deps: ctx.deps }) + ctx.agentResumeUnavailable = hostEnv.codexResumeLaunch.notifyResumeUnavailable + if (hostEnv.codexResumeLaunch.droppedResumeArgv && args.launchConfig) { + args.launchConfig = { + ...args.launchConfig, + agentCommand: hostEnv.launchCommand ?? args.launchConfig.agentCommand + } + } ctx.launchCommand = hostEnv.launchCommand ctx.codexResumeArgvDropped = hostEnv.codexResumeLaunch.droppedResumeArgv ctx.codexResumeUnavailable = hostEnv.codexResumeLaunch.notifyResumeUnavailable diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index d764b1fc4a1..93bf1708104 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -15,9 +15,13 @@ import type { PaneSpawnReservation } from '../pane/spawn-reservation' import type { AdoptStablePaneResult } from '../ipc/spawn-types' import type { PtyBindingSourceExpectation } from '../../../persistence' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' +import type { ResumeSpawnAdmission } from '../host-env/saved-resume-admission' import type { PtyRuntimeControllerDeps } from './controller-deps' import type { TuiAgent } from '../../../../shared/tui-agent' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { + SleepingAgentLaunchConfig, + AgentProviderSessionMetadata +} from '../../../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { AgentSessionExecutionClaim, @@ -51,6 +55,8 @@ export type RuntimePtySpawnState = { effectiveSessionAppId: string | undefined isNewDaemonSession: boolean expectedWslDistro: string | null + /** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */ + resumeAdmission: ResumeSpawnAdmission | null codexSelectionTarget: CodexAccountSelectionTarget launchCommand: string | undefined claudeAuth: ClaudeRuntimeAuthPreparation | null @@ -69,6 +75,7 @@ export type RuntimePtySpawnState = { requestedAgentTeamsPath: string | undefined selectedCodexHomePath: string | null codexResumeHomeSelected: boolean + agentResumeUnavailable: boolean codexResumeArgvDropped: boolean /** The dropped resume owes the user a notice: the pane runs a fresh session. */ codexResumeUnavailable: boolean @@ -107,6 +114,8 @@ export type RuntimePtySpawnArgs = { cwd?: string command?: string launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery telemetry?: { @@ -117,6 +126,8 @@ export type RuntimePtySpawnArgs = { env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ + resumeOrigin?: 'saved' connectionId?: string | null worktreeId?: string preAllocatedHandle?: string @@ -186,6 +197,7 @@ export function createRuntimePtySpawnState( effectiveSessionAppId: undefined, isNewDaemonSession: false, expectedWslDistro: null, + resumeAdmission: null, codexSelectionTarget: { runtime: 'host' }, launchCommand: undefined, claudeAuth: null, @@ -195,6 +207,7 @@ export function createRuntimePtySpawnState( requestedAgentTeamsPath: undefined, selectedCodexHomePath: null, codexResumeHomeSelected: false, + agentResumeUnavailable: false, codexResumeArgvDropped: false, codexResumeUnavailable: false, skipCodexHomeEnv: false, diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index c7626872eb1..71e27d10d0d 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -1,5 +1,12 @@ -import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' -import type { StartupCwdFallback } from '../../../../shared/startup-cwd-fallback' +import { + captureSavedResumeCleanup, + admitColdRestoreReattach, + savedResumeRecord, + savedResumeResult, + resumeSpawnRefusal, + forgetRefusedResume +} from '../host-env/saved-resume-admission' +import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { isTerminalLeafId, parsePaneKey } from '../../../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' @@ -16,24 +23,13 @@ import { releaseRuntimeSpawnPreSpawnHiddenMark } from './spawn-hidden-delivery' -function toRuntimeSpawnReply(result: { - id: string - incarnationId?: string - wslDistro?: string | null - stablePaneOwner?: { handle: string; tabId: string; leafId: string } - agentSessionEnsure?: AgentSessionClaimedSpawnResult - startupCwdFallback?: StartupCwdFallback - savedStartupCwd?: string -}) { - return { - savedStartupCwd: result.savedStartupCwd, - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}), - ...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), - ...(result.startupCwdFallback ? { startupCwdFallback: result.startupCwdFallback } : {}) +function toRuntimeSpawnReply( + result: PtySpawnResult & { + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + savedStartupCwd?: string } +) { + return { ...result, wslDistro: result.wslDistro ?? undefined } } export async function spawnPtyFromRuntimeController( @@ -72,6 +68,7 @@ export async function spawnPtyFromRuntimeController( if (!adopted) { return toRuntimeSpawnReply(freshResult!) } + Object.assign(adopted.result, deps.runtime?.getTerminalLaunchResult?.(adopted.result.id)) if (!args.worktreeId) { return toRuntimeSpawnReply(adopted.result) } @@ -92,11 +89,11 @@ async function spawnFreshPtyFromRuntimeController( const materialized = materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise if (materialized) { - return toRuntimeSpawnReply(materialized) + return { ...materialized, ...deps.runtime?.getTerminalLaunchResult?.(materialized.id) } } const earlyAdopt = await prepareRuntimePtySpawn(ctx) if (earlyAdopt) { - return toRuntimeSpawnReply(earlyAdopt) + return { ...earlyAdopt, ...deps.runtime?.getTerminalLaunchResult?.(earlyAdopt.id) } } const earlyReserved = await buildRuntimePtySpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize({ @@ -108,13 +105,60 @@ async function spawnFreshPtyFromRuntimeController( throw error }) if (earlyReserved) { - return toRuntimeSpawnReply(earlyReserved) + return { ...earlyReserved, ...deps.runtime?.getTerminalLaunchResult?.(earlyReserved.id) } } const complete = async (operation = args.paneSpawnOperation) => { ctx.args.paneSpawnOperation = operation await executeRuntimePtySpawn(ctx) + if (!ctx.resumeAdmission) { + ctx.resumeAdmission = await admitColdRestoreReattach( + deps.runtime, + ctx.result, + { + paneKey: ctx.spawnIdentityPaneKey, + saved: savedResumeRecord(deps.store, ctx.spawnIdentityPaneKey) + }, + ctx.expectedWslDistro + ) + await captureSavedResumeCleanup( + deps.runtime, + deps.store, + ctx.spawnIdentityPaneKey, + ctx.resumeAdmission, + args.worktreeId + ) + } + if (!ctx.result.isReattach) { + Object.assign(ctx.result, savedResumeResult(ctx.resumeAdmission), { + ...(ctx.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + launchConfig: args.launchConfig, + launchAgent: args.launchAgent, + launchToken: args.launchToken, + resumeProviderSession: ctx.codexResumeArgvDropped ? undefined : args.resumeProviderSession + }) + } const committed = await commitRuntimePtySpawn(ctx) + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + forgetRefusedResume(ctx.spawnIdentityPaneKey, refusal) + if ( + args.worktreeId && + ctx.spawnIdentityPaneKey && + refusal && + refusal.verdict !== 'unverifiable' + ) { + void deps.runtime + ?.forgetRefusedSavedResume?.( + args.worktreeId, + ctx.spawnIdentityPaneKey, + refusal.refused, + refusal.sourceRecord + ) + .catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error)) + } commitRuntimeSpawnHiddenDelivery(ctx) + if (!ctx.result.isReattach) { + deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) + } return committed } const latePane = @@ -135,6 +179,9 @@ async function spawnFreshPtyFromRuntimeController( preAllocatedHandle: args.preAllocatedHandle, spawnFresh: async (operation) => (result = await complete(operation)) }) + if (adopted) { + Object.assign(adopted.result, deps.runtime?.getTerminalLaunchResult?.(adopted.result.id)) + } return toRuntimeSpawnReply(adopted?.result ?? result!) } return await complete() diff --git a/src/main/ipc/pty/spawn-commit-assembly.ts b/src/main/ipc/pty/spawn-commit-assembly.ts index 5ed42acdd02..12762fb36a5 100644 --- a/src/main/ipc/pty/spawn-commit-assembly.ts +++ b/src/main/ipc/pty/spawn-commit-assembly.ts @@ -198,7 +198,7 @@ export async function commitPtySpawn( return resolvePaneSpawnReservation( state.paneSpawnReservationKey, state.paneSpawnReservation, - result + input.joinPayload.reply ) } } else if (input.earlyExit === 'throw') { @@ -225,7 +225,7 @@ function commitAdoptedAgentSession( input: PtySpawnCommitInput, adopted: AgentSessionClaimedSpawnResult, providerReattachLaunchIdentity: ReturnType -): Pick | Promise { +): PtySpawnResult | Promise { const { state } = input const { result } = state const runtime = state.deps.runtime @@ -272,9 +272,5 @@ function commitAdoptedAgentSession( state.paneSpawnReservation, adoptedResult ) - return { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - agentSessionEnsure: adopted - } + return adoptedResult } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 2cb505c7059..2ddef409a53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -43,7 +43,16 @@ export async function acquireOwner( throw new AgentSessionPreSpawnError(error) } const permissionMode = await input.launchPermissionMode?.(record) - // Recheck after waits so Stop/close/quit cannot spawn a late child. + try { + await input.assertNoTerminalWriter?.(record) + } catch (error) { + // Not a code: a code message would travel as the census's own conflict, not this reason. + throw new AgentSessionPreSpawnError(error, { + reason: 'conversationOpenInTerminal', + message: 'a terminal may still be writing this conversation' + }) + } + // Right before the spawn: a close, Stop or quit that landed during any wait above launches nothing. if (input.acquireSignal?.aborted) { throw new AgentSessionPreSpawnError(abortSignalReason(input.acquireSignal)) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts index 9b105cd29b0..5c4447b76a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts @@ -45,6 +45,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' | 'runtimeNotInstalled' + | 'conversationOpenInTerminal' > /** First-hand proof that acquisition spawned no process. */ 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 e4f18317540..5b8a718ef95 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 @@ -95,6 +95,9 @@ export type AttachFlowInput = { eventSink?: StructuredAgentSessionEventSink /** Prevent acquisition-window events from reaching superseded journals. */ onAcquiring?: () => Promise | void + /** Refuses the spawn while a terminal may still be writing the record's conversation. */ + assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise + /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void /** Adopt the conversation’s journal; attach never owns its open. */ openConversation: (record: AgentSessionRecord) => 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 9f180a6b64c..59ccc977485 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 @@ -243,6 +243,9 @@ async function runAttachUnderAbort( launchPermissionMode: async (record) => (await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ?? undefined, + assertNoTerminalWriter: async (record) => { + await lock.wait(Promise.resolve(context.deps.assertNoTerminalWriter?.(record))) + }, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { acquisition: true }) 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 de2367ac757..de412f22ac7 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 @@ -24,6 +24,7 @@ import { isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { + startupAcquisitionRefusal, providerExitObserved, structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' @@ -117,8 +118,9 @@ function failedAcquisitionDetails( if (argumentProblem) { return { reason: 'attachFailed', argumentProblem } } - if (error instanceof AgentSessionAcquisitionRefusal) { - return { reason: error.reason, ...(error.account ? { account: error.account } : {}) } + const typed = startupAcquisitionRefusal(error) + if (typed) { + return { reason: typed.reason, ...(typed.account ? { account: typed.account } : {}) } } if (isAgentSessionPreSpawnError(error) && error.reason) { return { reason: error.reason } 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 9f3b7dc5c66..643f6d1d0d5 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 @@ -36,7 +36,9 @@ const TYPED_START_REFUSALS = [ 'claudeAccountFolderMissing', 'claudeAccountSetupFailed', 'agentCommandNotRunnable', - 'runtimeNotInstalled' + 'runtimeNotInstalled', + 'conversationOpenInTerminal', + 'conversationOpenElsewhere' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] @@ -59,8 +61,28 @@ export function providerExitObserved(error: unknown): boolean { ) } -/** Preserve typed/observed causes and diagnostics; unclassified spawn/host errors cannot imply provider failure. */ +/** Cleanup wrappers retain the provider's typed refusal without changing their exit proof. */ +export function startupAcquisitionRefusal( + error: unknown, + depth = 0 +): AgentSessionAcquisitionRefusal | undefined { + if (depth >= 6 || !(error instanceof Error)) { + return undefined + } + if (error instanceof AgentSessionAcquisitionRefusal) { + return error + } + return ( + startupAcquisitionRefusal(error.cause, depth + 1) ?? + (error instanceof AggregateError + ? error.errors.map((inner) => startupAcquisitionRefusal(inner, depth + 1)).find(Boolean) + : undefined) + ) +} + +/** A failed start keeps its typed refusal or observed exit and private provider diagnostic. */ export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { + cause = startupAcquisitionRefusal(cause) ?? cause const installation = agentCliInstallationProblemOf(cause) if (installation) { return agentSessionFailureFact('startFailed', { 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 fedb1a28f64..ab3b8834980 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 @@ -143,7 +143,10 @@ export type StructuredAgentSessionHostDeps = { hasOpenDispatch?: (record: AgentSessionRecord) => boolean /** Advisory tab-hidden notice; failures are logged. */ onSessionTabHidden?: (sessionId: string) => void - /** Required to report failures that never reach the UI. */ + /** Throws while a terminal may still write the record's head conversation; run before a spawn. */ + assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise + /** Where every failure the host carries on past is reported. Required: a host without one would + * drop exactly the failures nobody sees in the UI. */ logger: StructuredAgentSessionLogger onRequestFailure?: (failure: StructuredAgentSessionRequestFailure) => void /** replay distinguishes restored/projected state from fresh journal events. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 50126091591..26e66bfacb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -1,3 +1,4 @@ +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import { expect, it, vi } from 'vitest' import type { StructuredAgentSessionProviderOpen, @@ -10,7 +11,7 @@ import { envelope, hostTestState } from './structured-agent-session-host-test-harness' -import { hostTestMessage } from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, hostTestMessage } from './structured-agent-session-host-test-data' const OPENED: StructuredAgentSessionProviderOpenResult = { reportedOptions: { model: 'gpt-test' } @@ -56,3 +57,24 @@ it('cancels the open on quit after a second client attach is refused', async () await ended } }) + +it('journals a typed writer refusal and cleans up the real starting child', async () => { + const { host, open, close, dispatch, pending, send } = awaitOpen() + await attach() + await send('continue C') + await vi.waitFor(() => expect(open).toHaveBeenCalledOnce()) + pending.reject( + new AgentSessionAcquisitionRefusal( + 'thread C already has an active writer', + 'conversationOpenElsewhere' + ) + ) + await vi.waitFor(async () => { + const snapshot = await host.journalSnapshot(HOST_TEST_SESSION) + expect( + snapshot.submissions.some((entry) => entry.rejection?.kind === 'conversationOpenElsewhere') + ).toBe(true) + }) + expect(close).toHaveBeenCalledOnce() + expect(dispatch).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts new file mode 100644 index 00000000000..a66804cd1a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -0,0 +1,440 @@ +import { + assertCliConversationWritersExited, + posixCliWriterShellAlone +} from '../../runtime/structured-agent-cli-writers' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +const physicalSnapshot = vi.hoisted(() => vi.fn()) +vi.mock('../../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getFreshShellForegroundSnapshot: physicalSnapshot +})) +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + NO_STRUCTURED_AGENTS, + structuredAgentsDeclaringNothing +} from './structured-agent-session-adapter-router-test-support' +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 { structuredAgentRuntimeRegistration } from '../../runtime/structured-agent-runtime-registrations' +import { attachFingerprintFields } from './structured-agent-session-attach' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let terminalWriting: boolean +const CALLER = { callerKey: 'client-1' } +const acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: fence === 1 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + } +})) +const assertNoTerminalWriter = vi.fn(async (_record: AgentSessionRecord) => { + if (terminalWriting) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } +}) + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-terminal-writer-acquire-')) + resetHostTestOperationIds() + terminalWriting = false + physicalSnapshot.mockReset() + acquire.mockClear() + assertNoTerminalWriter.mockReset().mockImplementation(async () => { + if (terminalWriting) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } + }) + const openCode = structuredAgentRuntimeRegistration('opencode') + if (!openCode) { + throw new Error('OpenCode is not registered') + } + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: structuredAgentsDeclaringNothing([ + ...NO_STRUCTURED_AGENTS.definitions(), + openCode.definition + ]), + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire, + dispatch: vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { provider: 'codex' as const, threadId: THREAD, turnId: 't', ordinal: 1 } + })), + 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), + open: async () => ({ reportedOptions: { model: '' } }) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + assertNoTerminalWriter, + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + closeTestJournalHostDatabase(root) + await rm(root, { recursive: true, force: true }) +}) + +async function send(text: string, sessionId = SESSION): Promise { + const body = hostTestMessage(text) + const envelope = { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + expect(await host.send(CALLER, { envelope, body })).toMatchObject({ ok: true }) + return envelope.clientOperationId +} + +async function rejection(id: string, sessionId = SESSION) { + await vi.waitFor( + async () => + expect( + (await host.journalSnapshot(sessionId)).submissions.find((s) => s.clientMessageId === id) + ?.dispatchState + ).toBe('rejected'), + { timeout: 10_000 } + ) + return (await host.journalSnapshot(sessionId)).submissions.find((s) => s.clientMessageId === id) +} + +// H10: a chat reopened while its conversation runs in a terminal starts no second writer. +it('refuses the chat agent start while a terminal writes its conversation, once per send', async () => { + await host.close(SESSION, 'evict') + terminalWriting = true + await host.revealSession(SESSION) + const spawnsBefore = acquire.mock.calls.length + assertNoTerminalWriter.mockClear() + + const first = await rejection(await send('first')) + expect(first?.rejection).toMatchObject({ kind: 'conversationOpenInTerminal' }) + expect(first?.reason).toBe( + 'Codex is already running this conversation in another terminal. Quit Codex there or close that terminal to continue here.' + ) + expect(acquire.mock.calls.length).toBe(spawnsBefore) + expect(store.getRecord(SESSION)?.lease.reservedSpawnToken).toBeNull() + + await rejection(await send('second')) + expect(assertNoTerminalWriter).toHaveBeenCalledTimes(2) + expect(acquire.mock.calls.length).toBe(spawnsBefore) + + terminalWriting = false + await send('after the terminal exits') + await vi.waitFor(() => expect(acquire.mock.calls.length).toBe(spawnsBefore + 1), { + timeout: 10_000 + }) +}) + +it('acquires when only a saved Sleep note remains, but refuses an existing unknown terminal', async () => { + const saved: SleepingAgentSessionRecord = { + paneKey: 'slept', + worktreeId: 'workspace-1', + agent: 'codex', + providerSession: { key: 'session_id', id: THREAD }, + prompt: '', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + let terminalExists = true + assertNoTerminalWriter.mockImplementation((record) => + assertCliConversationWritersExited( + { + saved: () => [saved], + rows: () => [], + links: () => [], + observation: () => null, + sleeping: () => true, + terminal: () => (terminalExists ? { pane: null } : null), + shellAlone: vi.fn(async () => false) + }, + record.provider, + record.providerHandleChain.at(-1)?.handle.nativeId ?? '' + ) + ) + await host.close(SESSION, 'evict') + await host.revealSession(SESSION) + const before = acquire.mock.calls.length + expect((await rejection(await send('unknown physical terminal')))?.rejection).toMatchObject({ + kind: 'conversationOpenInTerminal' + }) + expect(acquire).toHaveBeenCalledTimes(before) + terminalExists = false + const id = await send('saved note only') + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(before + 1)) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find((s) => s.clientMessageId === id) + ?.dispatchState + ).toBe('accepted') + ) +}) + +it('preserves the saved OpenCode guard through acquisition and the recorded send', async () => { + const sessionId = `${SESSION}-opencode` + const definition = structuredAgentRuntimeRegistration('opencode')?.definition + if (!definition) { + throw new Error('OpenCode is not registered') + } + const handle = { transport: definition.handleTransport, agent: 'opencode', nativeId: THREAD } + acquire.mockImplementationOnce(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4243, processStartTimeMs: NOW, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + const params = hostTestAttachParams(null, { + provider: 'opencode', + agent: 'opencode', + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } }, + providerHandle: undefined + }) + params.envelope.sessionId = sessionId + params.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId, + fields: attachFingerprintFields(params) + }) + const attached = await host.attach(CALLER, params) + expect(attached).toMatchObject({ ok: true }) + await host.close(sessionId, 'evict') + const saved: SleepingAgentSessionRecord = { + paneKey: 'slept', + worktreeId: 'workspace-1', + agent: 'opencode', + providerSession: { key: 'session_id', id: THREAD }, + prompt: '', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + assertNoTerminalWriter.mockImplementation((record) => + assertCliConversationWritersExited( + { + saved: () => [saved], + rows: () => [], + links: () => [], + observation: () => null, + sleeping: () => true, + terminal: () => null, + shellAlone: vi.fn(async () => false) + }, + record.provider, + record.providerHandleChain.at(-1)?.handle.nativeId ?? '' + ) + ) + await host.revealSession(sessionId) + const before = acquire.mock.calls.length + const rejected = await rejection(await send('resume saved OpenCode', sessionId), sessionId) + expect(rejected?.rejection).toMatchObject({ kind: 'conversationOpenInTerminal' }) + expect(rejected?.reason).toBe( + 'OpenCode is already running this conversation in another terminal. Quit OpenCode there or close that terminal to continue here.' + ) + expect(acquire).toHaveBeenCalledTimes(before) + expect(store.getRecord(sessionId)?.lease.reservedSpawnToken).toBeNull() +}) + +it.each([10, 100])( + 'drives acquisition through the real census for %i matching/unrelated panes', + async (size) => { + let enumerated = 0 + const saved: Record = Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + `pane-${index}`, + { + paneKey: `pane-${index}`, + worktreeId: 'workspace-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: THREAD }, + state: 'done' as const, + prompt: '', + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const + } + ]) + ) + const population = new Proxy(saved, { + ownKeys(target) { + const keys = Reflect.ownKeys(target) + enumerated += keys.length + return keys + } + }) + let rowVisits = 0 + let linkVisits = 0 + const rows = Array.from({ length: size }, (_, index) => ({ + paneKey: `unrelated-${index}`, + connectionId: null, + agentType: 'codex', + providerSession: { key: 'session_id' as const, id: 'unrelated' }, + state: 'done' as const, + prompt: '', + receivedAt: 1, + stateStartedAt: 1 + })) + const links = rows.map(() => ({ + agent: 'codex', + id: 'unrelated', + paneOwned: true, + worktreeId: 'workspace-1', + terminal: () => null + })) + rows.forEach((row, index) => + Object.defineProperty(rows, index, { + configurable: true, + enumerable: true, + get: () => { + rowVisits += 1 + return row + } + }) + ) + links.forEach((link, index) => + Object.defineProperty(links, index, { + configurable: true, + enumerable: true, + get: () => { + linkVisits += 1 + return link + } + }) + ) + rowVisits = 0 + const shell = [{ pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }] + physicalSnapshot.mockResolvedValue(shell) + const physical = vi.fn(() => posixCliWriterShellAlone(10)) + assertNoTerminalWriter.mockImplementation((record) => + assertCliConversationWritersExited( + { + saved: () => Object.values(population), + rows: () => rows, + links: () => links, + observation: () => null, + sleeping: () => false, + terminal: () => ({ pane: { ptyId: 'shell', incarnationId: 'i1', identity: 'shell:i1' } }), + shellAlone: physical + }, + record.provider, + record.providerHandleChain.at(-1)?.handle.nativeId ?? '' + ) + ) + const start = async () => { + await host.close(SESSION, 'evict') + await host.revealSession(SESSION) + const before = acquire.mock.calls.length + await send('acquire after census') + await vi.waitFor(() => expect(acquire.mock.calls.length).toBe(before + 1)) + } + for (const record of Object.values(saved)) { + record.providerSession.id = 'unrelated' + } + await start() + expect([enumerated, physical.mock.calls.length]).toEqual([2 * size, 0]) + expect([rowVisits, linkVisits]).toEqual([size, size]) + for (const record of Object.values(saved)) { + record.providerSession.id = THREAD + } + enumerated = 0 + rowVisits = 0 + linkVisits = 0 + await start() + // Across each physical await, existing saved-membership and final coverage revalidate the population. + expect(enumerated).toBe((6 * size + 2) * size) + expect([rowVisits, linkVisits]).toEqual([(4 * size + 1) * size, size]) + expect(physical).toHaveBeenCalledTimes(size) + expect(physicalSnapshot).toHaveBeenCalledTimes(size) + + await host.close(SESSION, 'evict') + await host.revealSession(SESSION) + physicalSnapshot.mockResolvedValue([ + ...shell, + { pid: 11, ppid: 10, stat: 'T', command: 'codex' } + ]) + physical.mockClear() + const before = acquire.mock.calls.length + await rejection(await send('live terminal witness')) + expect(acquire).toHaveBeenCalledTimes(before) + expect(physical).toHaveBeenCalledTimes(1) + + let finish = () => {} + physicalSnapshot.mockResolvedValue(shell) + physicalSnapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(shell) + }) + ) + physical.mockClear() + await send('cancel during physical proof') + await vi.waitFor(() => expect(physical).toHaveBeenCalledTimes(1)) + const closed = host.close(SESSION, 'evict') + finish() + await closed + await assertNoTerminalWriter.mock.results.at(-1)?.value + // Closing prevents the late spawn; this inherited census still finishes every physical proof. + expect(physical).toHaveBeenCalledTimes(size) + expect(acquire).toHaveBeenCalledTimes(before) + physicalSnapshot.mockResolvedValue(shell) + enumerated = 0 + await host.revealSession(SESSION) + await send('successful successor') + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(before + 1)) + expect(enumerated).toBeLessThanOrEqual((6 * size + 2) * size) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index 6991c55a3b2..f51ecc2ffb5 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,6 +1,12 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' import { structuredAgentRuntimeMetadata } from '../../runtime/structured-agent-runtime-metadata' +import { + keysMayMatch, + recordConversationKey, + type ConversationKey +} from '../structured-conversation-key' export type StructuredProviderSessionOwnership = { sessionId: string @@ -9,6 +15,9 @@ export type StructuredProviderSessionOwnership = { providerSessionId: string conversationName?: string lease: AgentSessionLease + /** `head`: the conversation the chat continues now; `earlier`: one its chain held before. */ + role: 'head' | 'earlier' + key: ConversationKey } /** Use registration-defined History row IDs for both ownership display and duplicate-adoption checks. */ @@ -17,15 +26,45 @@ export function listStructuredProviderSessionOwnership( ): StructuredProviderSessionOwnership[] { return records.flatMap((record) => { const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory - return history - ? record.providerHandleChain.map((link) => ({ - sessionId: record.sessionId, - workspaceId: record.location.workspaceId, - provider: record.provider, - providerSessionId: history.rowSessionId(link), - conversationName: record.conversationName, - lease: record.lease - })) + const head = record.providerHandleChain.at(-1) + const headKey = head ? recordConversationKey(record, head) : null + return history && headKey + ? record.providerHandleChain.flatMap((link) => { + const key = recordConversationKey(record, link) + return key + ? [ + { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + provider: record.provider, + providerSessionId: history.rowSessionId(link), + conversationName: record.conversationName, + lease: record.lease, + // A repeat resume link of the head's id is the head too. + role: keysMayMatch(key, headKey) ? ('head' as const) : ('earlier' as const), + key + } + ] + : [] + }) : [] }) } + +/** Fail closed: only a lease released on proof its owner exited can't be writing. */ +export function agentSessionLeasePossiblyWriting(lease: AgentSessionLease): boolean { + return agentSessionLeaseOwnerVerdict(lease) !== 'exited' +} + +/** Agents whose chats let a terminal resume the same conversation once the chat stopped writing. */ +export function sameIdTerminalResumeEnabled(_provider: StructuredAgentId): boolean { + return false +} + +/** Whether a terminal resume of this link's conversation is refused. */ +export function terminalResumeBlocked(holding: StructuredProviderSessionOwnership): boolean { + return ( + !sameIdTerminalResumeEnabled(holding.provider) || + (holding.role === 'head' && agentSessionLeasePossiblyWriting(holding.lease)) + ) +} diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index 3c73592b1c5..69530bc3a29 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -64,12 +64,13 @@ function ownership( provider: 'codex', providerSessionId: 'thread-1', lease: agentSessionLeaseFixture(), + role: 'head', ...overrides } } describe('findConflictingStructuredAdoption', () => { - it('names the session that already holds the conversation', () => { + it('names the session continuing the conversation now, never one that held it earlier', () => { const owner = ownership() expect( @@ -77,7 +78,11 @@ describe('findConflictingStructuredAdoption', () => { agent: 'codex', providerSessionId: 'thread-1', selfSessionId: 'codex_new', - ownership: [ownership({ sessionId: 'other', providerSessionId: 'thread-2' }), owner] + ownership: [ + ownership({ sessionId: 'other', providerSessionId: 'thread-2' }), + ownership({ sessionId: 'moved_on', role: 'earlier' }), + owner + ] }) ).toBe(owner) }) 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 64be4e40cad..a073cc0cbd5 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -22,6 +22,7 @@ export type StructuredAgentSessionAdoptionOwnership = { provider: StructuredAgentId providerSessionId: string lease: AgentSessionLease + role: 'head' | 'earlier' } export type StructuredAgentSessionAdoption = { @@ -83,6 +84,7 @@ export function findConflictingStructuredAdoption(input: { input.ownership.find( (owner) => owner.sessionId !== input.selfSessionId && + owner.role === 'head' && chatAgentsShareConversations(owner.provider, input.agent) && owner.providerSessionId === input.providerSessionId ) ?? null diff --git a/src/main/native-chat/structured-conversation-key.ts b/src/main/native-chat/structured-conversation-key.ts new file mode 100644 index 00000000000..64dcc18d69d --- /dev/null +++ b/src/main/native-chat/structured-conversation-key.ts @@ -0,0 +1,66 @@ +import { + chatAgentsShareConversations, + CLI_CONVERSATION_ADOPTING_AGENTS +} from '../../shared/structured-agent-cli-conversation-adoption' +import { + agentSessionAccountHomesEqual, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { structuredAgentRuntimeMetadata } from '../runtime/structured-agent-runtime-metadata' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' + +/** One conversation wherever it is named: the agents sharing its ids, its id (null: a targetless + * resume that may pick any of the family's), and its account home (null: the namer can't tell). */ +export type ConversationKey = { + family: string + id: string | null + home: AgentSessionAccountHome | null +} + +const FAMILY_MEMBERS = [...new Set(Object.entries(CLI_CONVERSATION_ADOPTING_AGENTS).flat(2))].sort() + +/** Agents that open one conversation's ids (`opencode`, `opencode2`) share one family name. */ +export function conversationFamily(agent: string): string { + return FAMILY_MEMBERS.find((member) => chatAgentsShareConversations(member, agent)) ?? agent +} + +export function recordConversationKey( + record: AgentSessionRecord, + link: AgentSessionProviderHandleLink +): ConversationKey | null { + const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory + return history + ? { + family: conversationFamily(record.provider), + id: history.rowSessionId(link), + home: record.accountHome + } + : null +} + +/** A terminal names a conversation by the id its hooks or command carry; its home is unknown. */ +export function terminalConversationKey(agent: string, id: string | null): ConversationKey { + return { family: conversationFamily(agent), id: id?.trim() || null, home: null } +} + +export function keysMayMatch(left: ConversationKey, right: ConversationKey): boolean { + return ( + left.family === right.family && + (left.id === null || right.id === null || sameConversationId(left.family, left.id, right.id)) && + (left.home === null || + right.home === null || + agentSessionAccountHomesEqual(left.home, right.home)) + ) +} + +// A session-file agent's terminal may name the file; Session History lists its file-name id. +function sameConversationId(family: string, left: string, right: string): boolean { + if (family === 'opencode') { + return left === right + } + const normalize = (id: string) => + (/[\\/]/.test(id) ? sessionIdFromFileName(id) : id).toLowerCase() + return normalize(left) === normalize(right) +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 19607c23010..a651ae7a65e 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -1,3 +1,4 @@ +import type { TerminalLaunchResult } from '../../shared/agent-session-resume' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' @@ -5,7 +6,7 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { TerminalOwner } from '../../shared/terminal-owner' import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' -export type PtySpawnResult = { +export type PtySpawnResult = TerminalLaunchResult & { /** Execution-host probe; absent on older hosts and reattachments. */ openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts index 152ad52a6f1..f72d4c589f2 100644 --- a/src/main/runtime/agent-session-record-store-slot.ts +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -1,6 +1,9 @@ // Launch admission opens the single-writer store before chat-host installation; both must reuse this instance. // Quit-only edge: failed host teardown can leave its connection open after releasing the slot; a post-stop open lasts until exit. +import { existsSync } from 'node:fs' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' import { AgentSessionRecordStore } from './agent-session-record-store' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' @@ -81,3 +84,20 @@ export async function releaseAgentSessionRecordStore(): Promise null) : null } + +/** Whether this profile holds a structured chat: a record or tab in the journal database. */ +export function hasPersistedStructuredAgentSessionStore( + stateDirectory: string, + fileExists: (path: string) => boolean = existsSync +): boolean { + const databasePath = journalDatabasePath(stateDirectory) + if (!fileExists(databasePath)) { + return false + } + try { + return journalDatabaseHoldsAgentSessions(databasePath) + } catch { + // A database that cannot be read cannot say it is empty. + return true + } +} diff --git a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts index d98cf1cf06d..c2644c8287b 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts @@ -138,6 +138,29 @@ describe('adopted conversation ownership', () => { ).not.toThrow() }) + it('admits a conversation another record only held before its current one', () => { + const fixture = agentSessionRecordFixture() + const holder: AgentSessionRecord = { + ...fixture, + providerHandleChain: [ + ...fixture.providerHandleChain, + adoptedLink({ + linkId: 'later', + handle: claudeProviderHandle('provider-session-later', null), + origin: 'created' + }) + ] + } + + expect(() => + applyAgentSessionReservation( + storeState([holder]), + reserveRequest({ adoptedHandleLink: adoptedLink() }), + LEASE_TTL_MS + ) + ).not.toThrow() + }) + it('preserves the adopted chain and launch pin when retrying an existing reservation', () => { // Pin self-exemption directly; create-by-intent has null expectedFence and rejects existing ids before this can act as replay. const link = adoptedLink() diff --git a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts index 7a6e07428e0..5e128514908 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts @@ -210,7 +210,7 @@ export function applyAgentSessionReservation( }) } -/** Refuse conversations held by other records, including other branches of their root. +/** Refuse another record's current conversation, matching the pre-commit check. * Run inside the transaction: distinct adoption ids evade per-record CAS and providers may permit corrupting concurrent writers. * Self-exemption is not replay; the operation ledger resolves replay before this guard. */ export function assertAdoptedConversationUnowned( @@ -226,14 +226,15 @@ export function assertAdoptedConversationUnowned( if (record.sessionId === request.sessionId) { continue } - const holdsSameConversation = record.providerHandleChain.some( - (link) => - agentSessionProviderHandleRoot(link.handle) === root || + // Only the conversation a chat continues now: an earlier link no longer holds anything. + const head = record.providerHandleChain.at(-1) + const holdsSameConversation = + !!head && + (agentSessionProviderHandleRoot(head.handle) === root || // One id from two chat agents that open the same conversations (`opencode`, `opencode2`). - (link.handle.transport === adopted.handle.transport && - link.handle.nativeId === adopted.handle.nativeId && - chatAgentsShareConversations(link.handle.agent, adopted.handle.agent)) - ) + (head.handle.transport === adopted.handle.transport && + head.handle.nativeId === adopted.handle.nativeId && + chatAgentsShareConversations(head.handle.agent, adopted.handle.agent))) if (holdsSameConversation) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'conversationHeldElsewhere', diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 2d47a243d10..2e2c64da6f6 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -1,6 +1,17 @@ /** Host creates need durable binding/runtime ownership to survive graph sync without a renderer writer. */ import { describe, expect, it, vi } from 'vitest' +import './rpc/unused-default-rpc-methods.test-fixture' +import { + startDesktopMultiplexSubscribe, + sendDesktopMultiplexSubscribe +} from './rpc/terminal-multiplex-test-harness' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { OrcaRuntimeService } from './orca-runtime' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../shared/terminal-stream-protocol' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }, @@ -68,3 +79,245 @@ describe('host-initiated terminal creation under an attached window', () => { expect(pty?.runtimeSessionOwned).toBe(true) }) }) + +it.each(['refused', 'adopted', 'stable-adopted', 'ensure-adopted', 'fresh-unavailable'] as const)( + 'publishes the effective %s terminal rather than the requested saved recipe', + async (kind) => { + const { runtime, spawn } = createRuntimeWithAttachedWindow() + const config = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + const adopted = kind === 'adopted' || kind === 'stable-adopted' || kind === 'ensure-adopted' + const ownerSurface = { + worktreeId: 'repo-1::/tmp/wt-cli', + terminalHandle: 'owner-handle', + tabId: 'owner-tab', + leafId: '11111111-1111-4111-8111-111111111111' + } + if (adopted && kind !== 'adopted') { + // An adopted surface is registered; a bare provider reattach need not be. + runtime.registerPty('effective-pty', ownerSurface.worktreeId, null, { + tabId: ownerSurface.tabId, + leafId: ownerSurface.leafId, + incarnationId: 'physical-i1', + terminalHandle: ownerSurface.terminalHandle + }) + } + const coldRestore = { scrollback: 'checkpoint A', cwd: '/tmp/wt-cli', cols: 80, rows: 24 } + spawn.mockResolvedValue({ + id: 'effective-pty', + pid: 42, + incarnationId: 'physical-i1', + coldRestore, + snapshot: 'current image B', + snapshotCols: 80, + snapshotRows: 24, + snapshotSeq: 7, + providerSequence: { value: 7, generation: 'continued' }, + attachedGrid: { cols: 80, rows: 24 }, + isAlternateScreen: false, + replay: 'provider backlog', + shellReadyArmed: true, + openCodeCapabilities: { version: '2.0.16', pluginApi: 'v2', promptMode: 'prefill' }, + ...(adopted + ? { launchAgent: 'codex', launchConfig: config, launchToken: 'owner-token' } + : {}), + ...(kind === 'adopted' ? { isReattach: true } : {}), + ...(kind === 'stable-adopted' + ? { + stablePaneOwner: { + handle: ownerSurface.terminalHandle, + tabId: ownerSurface.tabId, + leafId: ownerSurface.leafId + } + } + : {}), + ...(kind === 'ensure-adopted' + ? { + agentSessionEnsure: { + disposition: 'adopted', + owner: { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'C', + worktreeScopeDigest: 'wt', + agent: 'codex' + }, + generation: 'owner-generation', + phase: 'live', + ptyId: 'effective-pty', + surface: ownerSurface + } + } + } + : {}), + ...(kind === 'refused' + ? { + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true, + savedResumeDecision: { + agent: 'claude', + providerSession: { key: 'session_id', id: 'C' }, + verdict: 'chat-home' + } + } + : {}), + ...(kind === 'fresh-unavailable' + ? { + agentResumeUnavailable: true, + launchAgent: 'codex', + launchConfig: config, + launchToken: 'actual-fresh-token' + } + : {}) + }) + const result = await runtime.createTerminal('id:repo-1::/tmp/wt-cli', { + launchAgent: 'claude', + title: 'Rejected C', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: 'C' }, + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} } + }) + expect(result).toEqual({ + handle: expect.any(String), + tabId: expect.any(String), + paneKey: expect.any(String), + ptyId: 'effective-pty', + savedStartupCwd: '/tmp/wt-cli', + worktreeId: 'repo-1::/tmp/wt-cli', + title: kind === 'fresh-unavailable' ? 'Rejected C' : null, + launchConfig: kind === 'refused' ? undefined : config, + launchAgent: kind === 'refused' ? undefined : 'codex', + launchToken: kind === 'refused' ? undefined : adopted ? 'owner-token' : 'actual-fresh-token', + executionHostId: 'local', + hostPlatform: process.platform, + incarnationId: 'physical-i1', + surface: 'background', + processId: 42, + ...(adopted ? { isReattach: true } : { coldRestore }), + ...(kind === 'ensure-adopted' ? { agentSessionDisposition: 'adopted' } : {}), + ...(!adopted ? { agentResumeUnavailable: true } : {}), + ...(kind === 'refused' + ? { + agentResumeRefused: 'chat-home', + savedResumeDecision: { + agent: 'claude', + providerSession: { key: 'session_id', id: 'C' }, + verdict: 'chat-home' + } + } + : {}), + warning: expect.any(String) + }) + expect(runtime.resolveTerminalPane(result.paneKey ?? '', result.worktreeId)).toMatchObject({ + handle: result.handle, + ptyId: 'effective-pty', + connected: true + }) + expect(result.agentResumeRefused).toBe(kind === 'refused' ? 'chat-home' : undefined) + expect(result.launchAgent).toBe(kind === 'refused' ? undefined : 'codex') + expect(result.launchConfig).toEqual(kind === 'refused' ? undefined : config) + if (kind === 'refused') { + expect(result.title).not.toBe('Rejected C') + } + if (kind === 'fresh-unavailable') { + expect(result.launchToken).toBe('actual-fresh-token') + } + } +) + +it.each(['unchanged', 'hook', 'config', 'incarnation', 'handle'] as const)( + 'samples the actual created PTY after snapshot awaits and revalidates %s on reconnect', + async (change) => { + const { runtime, spawn } = createRuntimeWithAttachedWindow() + const providerSession = { key: 'session_id' as const, id: 'C' } + spawn.mockResolvedValue({ + id: 'effective-pty', + incarnationId: 'i1', + agentResumeUnavailable: true, + agentResumeRefused: 'chat-home', + savedResumeDecision: { agent: 'claude', providerSession, verdict: 'chat-home' } + }) + const terminal = await runtime.createTerminal('id:repo-1::/tmp/wt-cli', { + tabId: 'saved-tab', + leafId: '11111111-1111-4111-8111-111111111111', + launchAgent: 'claude', + resumeOrigin: 'saved', + resumeProviderSession: providerSession + }) + let snapshotStarted = false + let finishSnapshot = () => {} + let handle = terminal.handle + const harness = startDesktopMultiplexSubscribe({ + resolveLiveLeafForHandle: () => runtime.resolveLiveLeafForHandle(handle), + getPtyExecutionHostMetadata: runtime.getPtyExecutionHostMetadata.bind(runtime), + getTerminalLaunchResult: runtime.getTerminalLaunchResult.bind(runtime), + serializeTerminalBuffer: () => + new Promise((resolve) => { + snapshotStarted = true + finishSnapshot = () => resolve({ data: 'shell$ ', cols: 80, rows: 24 }) + }) + }) + try { + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => expect(snapshotStarted).toBe(true)) + if (change === 'hook') { + runtime.recordCliWriterConversation({ + paneKey: 'saved-tab:11111111-1111-4111-8111-111111111111', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + providerSession, + payload: { state: 'done', prompt: '', agentType: 'claude' }, + agentPresence: { + agent: 'claude', + process: { pid: 12, platform: 'darwin', startTime: 's' } + } + }) + } else if (change === 'config') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime owns this PTY map; mutate its actual record to model a replacement launch. + const records = (runtime as unknown as { ptysById: Map }) + .ptysById + const pty = records.get('effective-pty')! + pty.launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + } else if (change === 'incarnation') { + runtime.registerPty('effective-pty', 'repo-1::/tmp/wt-cli', null, { + tabId: 'saved-tab', + leafId: '11111111-1111-4111-8111-111111111111', + incarnationId: 'i2' + }) + } else if (change === 'handle') { + handle = 'stale-handle' + } + finishSnapshot() + const events = () => harness.messages.map((message) => JSON.parse(message).result) + const stale = change === 'handle' || change === 'incarnation' + await vi.waitFor(() => + expect(events().some((event) => event?.type === (stale ? 'end' : 'subscribed'))).toBe(true) + ) + if (stale) { + expect(events().some((event) => event?.type === 'subscribed')).toBe(false) + const error = harness.binaryFrames + .map(decodeTerminalStreamFrame) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Error) + expect(error && decodeTerminalStreamText(error.payload)).toBe('terminal_handle_stale') + } else { + const result = events().find((event) => event?.type === 'subscribed').launchResult + expect(result).toMatchObject({ ptyId: 'effective-pty', incarnationId: 'i1' }) + expect(result.agentResumeRefused).toBe(change === 'unchanged' ? 'chat-home' : undefined) + harness.messages.splice(0) + snapshotStarted = false + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => expect(snapshotStarted).toBe(true)) + finishSnapshot() + await vi.waitFor(() => + expect(events().some((event) => event?.type === 'subscribed')).toBe(true) + ) + expect(events().find((event) => event?.type === 'subscribed').launchResult).toEqual(result) + } + } finally { + harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint') + await harness.dispatchPromise + } + } +) diff --git a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts index 7ac41fbbc0d..7e22efaa169 100644 --- a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts +++ b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts @@ -161,3 +161,115 @@ describe('a queued hook status and a workspace sleep', () => { }) }) }) + +it.each([false, true])( + 'orders refused cleanup after queued status and preserves newer same-conversation recipe (%s)', + async (newer) => { + const { runtime, getSession, releaseFirstWrite } = makeRuntimeWithSlowFirstWrite() + runtime.recordHookStatusResume(codexRow()) + await vi.waitFor(() => + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeDefined() + ) + runtime.recordHookStatusResume(codexRow({ state: 'blocked', receivedAt: 11 })) + const sourcePromise = runtime.readSavedResumeSource(TEST_WORKTREE_ID, PANE_KEY) + releaseFirstWrite() + const source = await sourcePromise + if (!source) { + throw new Error('missing captured source') + } + const refused = { + agent: 'codex' as const, + providerSession: SESSION, + expectedStatus: { paneKey: PANE_KEY, receivedAt: 11, stateStartedAt: 10 }, + expectedSavedRecord: { + paneKey: PANE_KEY, + capturedAt: source.capturedAt, + updatedAt: source.updatedAt + } + } + if (newer) { + runtime.recordHookStatusResume(codexRow({ receivedAt: 30, prompt: 'new explicit C' })) + } + await runtime.forgetRefusedSavedResume(TEST_WORKTREE_ID, PANE_KEY, refused, source) + await runtime['hookStatusResumeWrites'] + const record = getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] + if (newer) { + expect(record).toMatchObject({ prompt: 'new explicit C', updatedAt: 30 }) + } else { + expect(getSession().sleepingAgentSessionsByPaneKey).toEqual({}) + } + } +) + +it.each([10, 100])( + 'counts actual refused cleanup copies/writes over %i saved panes', + async (size) => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const source = sleepCapture(11).get(PTY_ID)!.records[0]! + session.sleepingAgentSessionsByPaneKey = Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + `pane-${index}`, + { ...source, paneKey: `pane-${index}` } + ]) + ) + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore) + const originalClone = globalThis.structuredClone + let copied = 0 + let clones = 0 + const clone = vi.spyOn(globalThis, 'structuredClone').mockImplementation((value, options) => { + if (value && typeof value === 'object' && 'sleepingAgentSessionsByPaneKey' in value) { + copied += Object.keys(value.sleepingAgentSessionsByPaneKey ?? {}).length + clones += 1 + } + return originalClone(value, options) + }) + const clear = (index: number, stale = false) => { + const paneKey = `pane-${index}` + const record = { ...source, paneKey } + return runtime.forgetRefusedSavedResume( + TEST_WORKTREE_ID, + paneKey, + { + agent: 'codex', + providerSession: SESSION, + expectedSavedRecord: { + paneKey, + capturedAt: stale ? -1 : record.capturedAt, + updatedAt: record.updatedAt + } + }, + record + ) + } + try { + await clear(0, true) + expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([1, size, 0]) + // The durable fixture executes the real mutation/rollback, failing only its disk-write primitive. + runtimeStore.flushOrThrow.mockImplementationOnce(() => { + throw new Error('disk full') + }) + await expect(clear(0)).rejects.toThrow('disk full') + expect(getSession().sleepingAgentSessionsByPaneKey?.['pane-0']).toBeDefined() + clones = 0 + copied = 0 + runtimeStore.flushOrThrow.mockClear() + await Promise.all(Array.from({ length: size }, (_, index) => clear(index))) + // Each accepted edit clones before deleting, then stages one smaller map: sum(2k-1)=N². + expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([ + 2 * size, + size * size, + size + ]) + expect(getSession().sleepingAgentSessionsByPaneKey).toEqual({}) + await clear(0) + expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([ + 2 * size + 1, + size * size, + size + ]) + } finally { + clone.mockRestore() + } + } +) diff --git a/src/main/runtime/host-sleep-resume-record.test.ts b/src/main/runtime/host-sleep-resume-record.test.ts index 813aabd9727..37417aa4d41 100644 --- a/src/main/runtime/host-sleep-resume-record.test.ts +++ b/src/main/runtime/host-sleep-resume-record.test.ts @@ -135,6 +135,7 @@ describe('host-driven worktree sleep keeps the slept agent resumable', () => { expect(spawnCommand(spawn, 1)).toContain('--dangerously-skip-permissions') expect(spawnCommand(spawn, 1)).toContain(SESSION_ID) + expect(spawn.mock.calls[1]?.[0]).toHaveProperty('resumeOrigin', 'saved') }) it.each([ diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts index abe26419cfe..fb949ea8385 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts @@ -11,9 +11,20 @@ import { makeWorkspaceSessionWithHeadlessTerminal } from '../orca-runtime-test-fixtures.spec' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import type { TerminalLaunchResult } from '../../../shared/agent-session-resume' import type { PtyProcessInfo } from '../../providers/pty-provider-contract' import { deterministicAgentSessionUuid } from '../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../remote-runtime-terminal-create-identity' +import type { RuntimePtyController } from '../runtime-pty-controller-contract' +import { AgentSessionRecordStore } from '../agent-session-record-store' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { + chooseSavedResumeFreshLaunch, + savedResumeResult +} from '../../ipc/pty/host-env/saved-resume-admission' const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` const SESSION = { key: 'session_id', id: 'provider-session-1' } as const @@ -72,7 +83,7 @@ function sessionRow(paneKey: string, receivedAt = 0): AgentStatusIpcPayload { function makeRuntime( fixture: Fixture, opts: { - spawn?: () => Promise<{ id: string }> + spawn?: NonNullable rows?: () => AgentStatusIpcPayload[] checkHookAgentPresence?: () => Promise<'live' | 'unverifiable' | 'exited' | null> listProcesses?: () => Promise @@ -81,7 +92,7 @@ function makeRuntime( ) => () => void } = {} ) { - const spawn = vi.fn<(args: unknown) => Promise<{ id: string }>>( + const spawn = vi.fn>( opts.spawn ?? (() => Promise.resolve({ id: 'resumed-pty' })) ) const runtime = new OrcaRuntimeService(fixture.runtimeStore, undefined, { @@ -147,6 +158,135 @@ function storedRecord(fixture: Fixture) { } describe('resumeAgentSessionInWorkspace', () => { + it.each(['live', 'worktree-sleep'] as const)( + 'releases a %s saved resume claim when actual admission creates a plain shell', + async (origin) => { + const fixture = makeSleptWorkspace() + const saved = storedRecord(fixture) + if (!saved) { + throw new Error('missing saved conversation') + } + saved.origin = origin + const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) + ) + chat.providerHandleChain[0]!.handle.nativeId = SESSION.id + let nativeOpen = true + const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + store.listRecords = () => [chat] + store.getVisibleSessionTabIndex = () => ({ + present: true, + sessionIds: nativeOpen ? [chat.sessionId] : [] + }) + const effectiveCommands: (string | undefined)[] = [] + const { runtime, spawn, openOldPane, resume } = makeRuntime(fixture, { + spawn: async (args) => { + const admission = await chooseSavedResumeFreshLaunch( + { openAgentSessionRecordStore: async () => store }, + args, + null, + args + ) + effectiveCommands.push(args.command) + return { + id: effectiveCommands.length === 1 ? 'refused-shell' : 'resumed-C', + launchAgent: args.launchAgent, + launchConfig: args.launchConfig, + ...savedResumeResult(admission) + } + } + }) + await openOldPane() + expect(runtime.getTerminalLaunchResult('refused-shell')).toMatchObject({ + agentResumeRefused: 'chat-home', + savedResumeDecision: { agent: 'claude', providerSession: SESSION, verdict: 'chat-home' } + }) + expect(runtime.getTerminalLaunchResult('refused-shell')?.launchAgent).toBeUndefined() + expect(effectiveCommands).toEqual([undefined]) + nativeOpen = false + await expect(resume({ mutationId: 'after-native-close' })).resolves.toMatchObject({ + ok: true, + ptyId: 'resumed-C' + }) + expect(effectiveCommands[1]).toContain(SESSION.id) + expect(spawn).toHaveBeenCalledTimes(2) + expect(runtime.getTerminalLaunchResult('refused-shell')).toBeDefined() + } + ) + + it('settles only fresh matching saved results and binds unproven or observed results until exit', async () => { + for (const kind of [ + 'matching', + 'unverifiable', + 'observed', + 'other-session', + 'other-agent', + 'missing', + 'contradictory', + 'unknown' + ]) { + const fixture = makeSleptWorkspace() + const record = storedRecord(fixture)! + record.origin = 'live' + const matching: TerminalLaunchResult = savedResumeResult({ + verdict: kind === 'unverifiable' ? 'unverifiable' : 'chat-home', + refused: { agent: 'claude', providerSession: SESSION } + }) + if (!matching.savedResumeDecision) { + throw new Error('Saved admission must return its decision') + } + const receipt = + kind === 'unknown' + ? {} + : { + ...matching, + savedResumeDecision: + kind === 'missing' + ? undefined + : { + ...matching.savedResumeDecision, + agent: kind === 'other-agent' ? 'codex' : 'claude', + providerSession: kind === 'other-session' ? { ...SESSION, id: 'D' } : SESSION, + verdict: + kind === 'contradictory' + ? ('admitted' as const) + : matching.savedResumeDecision.verdict + }, + ...(kind === 'observed' ? { isReattach: true } : {}) + } + let first = true + const { runtime, spawn, openOldPane, resume } = makeRuntime(fixture, { + spawn: async (args) => { + if (!first) { + return { id: 'next-C' } + } + first = false + expect(args).toMatchObject({ + launchAgent: 'claude', + resumeOrigin: 'saved', + resumeProviderSession: SESSION + }) + return { id: 'result-pty', ...receipt } + } + }) + await openOldPane() + const proved = kind === 'matching' || kind === 'unverifiable' + const action = { mutationId: `after-${kind}` } + if (!proved) { + await expect(resume(action)).resolves.toEqual({ ok: false, reason: 'held_elsewhere' }) + expect(spawn).toHaveBeenCalledOnce() + runtime.onPtyExit('result-pty', 0) + } + await expect(resume(action)).resolves.toMatchObject({ ok: true, ptyId: 'next-C' }) + expect(spawn).toHaveBeenCalledTimes(2) + } + }) + it('retains ownership after an ambiguous create with a running resume', async () => { const fixture = makeSleptWorkspace() const handle = deriveRemoteRuntimeTerminalCreateHandle( diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts index 041c4465d81..1066394cbf8 100644 --- a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -120,7 +120,11 @@ describe('terminal stream extraction characterization', () => { pendingOutputOverflowed: true, outputBatcher: { push: vi.fn(), flush: vi.fn() } } as unknown as TerminalMultiplexStream + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the runtime reads made by initial snapshot publication. const runtime = { + getPtyExecutionHostMetadata: () => ({}), + getTerminalLaunchResult: () => undefined, + resolveLiveLeafForHandle: () => ({ ptyId: stream.ptyId }), readTerminal: vi.fn(async () => { readCount += 1 if (readCount === 2) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index e092e717664..daa977accea 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -94,6 +94,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ ...(params.resumeProviderSession ? { resumeProviderSession: params.resumeProviderSession } : {}), + ...(params.resumeOrigin ? { resumeOrigin: params.resumeOrigin } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), ...(params.terminalKittyKeyboardProtocol === true diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index 3a2a803a2de..adcb88d4960 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -21,7 +21,8 @@ export type MultiplexPublishedInitialState = { export async function publishMultiplexInitialSnapshot( state: TerminalMultiplexConnection, request: MultiplexSubscribeRequest, - stream: TerminalMultiplexStream + stream: TerminalMultiplexStream, + incarnationId = state.runtime.getPtyExecutionHostMetadata(stream.ptyId).incarnationId ): Promise { const { runtime, emit } = state const { ptyId } = stream @@ -55,7 +56,23 @@ export async function publishMultiplexInitialSnapshot( const layoutSeq = runtime.getLayout(ptyId)?.seq // Why: layout versions and output offsets are different sequence domains. const snapshotOutputSeq = serialized?.seq + // Snapshot awaits may outlive the handle binding or the physical shell. + if ( + runtime.resolveLiveLeafForHandle(request.terminal)?.ptyId !== ptyId || + runtime.getPtyExecutionHostMetadata(ptyId).incarnationId !== incarnationId + ) { + throw new Error('terminal_handle_stale') + } + let launchResult: ReturnType + try { + launchResult = runtime.getTerminalLaunchResult(ptyId) + } catch (error) { + console.warn('Terminal launch result unavailable', error) + } emit({ + ...(incarnationId && launchResult + ? { launchResult: { ...launchResult, ptyId, incarnationId } } + : {}), type: 'subscribed', streamId: request.streamId, terminal: request.terminal, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts index 3800fa08a05..345203f00ce 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts @@ -21,6 +21,7 @@ export function installMultiplexSubscribeFrame(state: TerminalMultiplexConnectio if (!ptyId) { return } + const incarnationId = state.runtime.getPtyExecutionHostMetadata(ptyId).incarnationId let stream: TerminalMultiplexStream | null = null let installedStream: TerminalMultiplexStream | null = null try { @@ -46,7 +47,7 @@ export function installMultiplexSubscribeFrame(state: TerminalMultiplexConnectio return } try { - const published = await publishMultiplexInitialSnapshot(state, request, stream) + const published = await publishMultiplexInitialSnapshot(state, request, stream, incarnationId) if (!published) { return } diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 2e109eb2894..926b15d7e42 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -16,8 +16,11 @@ import { // Preserve pendingEscapeTailAnsi through SnapshotStart (#7329); after reset, missing partial escapes make subsequent live bytes render literally. function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. return { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts index f0aa1669b9e..c47af43d786 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts @@ -108,14 +108,18 @@ describe('terminal multiplex RPC', () => { }) it("waits for a desktop multiplex subscriber's PTY before retiring the terminal", async () => { + let ptyId: string | null = null let resolvePty: (ptyId: string) => void = () => {} const runtime = stubRuntime({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId })), requestRendererTerminalTabMount: vi.fn(), waitForLeafPtyId: vi.fn( () => new Promise((resolve) => { - resolvePty = resolve + resolvePty = (resolvedPtyId) => { + ptyId = resolvedPtyId + resolve(resolvedPtyId) + } }) ) }) @@ -359,17 +363,23 @@ describe('terminal multiplex RPC', () => { const activeStreamCount = 44 const waitSignals: AbortSignal[] = [] const resolveWaits: ((ptyId: string) => void)[] = [] + const ptyIds = new Map() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn((terminal: string) => - terminal.startsWith('pending-') ? { ptyId: null } : { ptyId: `pty-${terminal}` } + terminal.startsWith('pending-') + ? { ptyId: ptyIds.get(terminal) ?? null } + : { ptyId: `pty-${terminal}` } ), waitForLeafPtyId: vi.fn( - (_handle: string, _timeoutMs?: number, signal?: AbortSignal) => + (handle: string, _timeoutMs?: number, signal?: AbortSignal) => new Promise((resolve, reject) => { if (signal) { waitSignals.push(signal) } - resolveWaits.push(resolve) + resolveWaits.push((ptyId) => { + ptyIds.set(handle, ptyId) + resolve(ptyId) + }) signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { once: true }) diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts index 2a4f86faf09..88b0f516662 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts @@ -15,15 +15,22 @@ import { startDesktopMultiplexSubscribe } from './terminal-multiplex-test-harnes function startPendingPtyHarness() { const waits = new Map void }>() + const ptyIds = new Map() const requestRendererTerminalTabMount = vi.fn() const harness = startDesktopMultiplexSubscribe({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + resolveLiveLeafForHandle: vi.fn((handle: string) => ({ ptyId: ptyIds.get(handle) ?? null })), requestRendererTerminalTabMount, waitForLeafPtyId: vi.fn( (handle: string, _timeoutMs?: number, signal?: AbortSignal) => new Promise((resolve, reject) => { if (signal) { - waits.set(handle, { signal, resolve }) + waits.set(handle, { + signal, + resolve: (ptyId) => { + ptyIds.set(handle, ptyId) + resolve(ptyId) + } + }) } signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { once: true diff --git a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts index 2996aecace6..973c9b7b461 100644 --- a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts @@ -49,8 +49,11 @@ async function setupMultiplexStream(): Promise<{ return { data: snapshot.data, cols: 80, rows: 24, seq: snapshot.seq } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. const runtime = { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: serializeSnapshot, diff --git a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts index 899b476e6ba..c3e62e7de85 100644 --- a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts +++ b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts @@ -25,6 +25,8 @@ export function stubRuntime(overrides: Partial = {}): OrcaRu // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. return { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({}), + getTerminalLaunchResult: () => undefined, subscribeToPtyExit: vi.fn(() => vi.fn()), // View registration suppresses competing query responders (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, diff --git a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts index b658e9814bc..04e00788ffc 100644 --- a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts +++ b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts @@ -50,8 +50,11 @@ async function requestSnapshotReply(options: { options.duringSerialize?.(requestedAttempt, (data) => dataListenerRef.current?.(data)) return options.serializeRequested(requestedAttempt) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. const runtime = { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, registerRemoteTerminalViewSubscriber: () => () => {}, resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount: vi.fn().mockReturnValue(true), diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 7a4cf3d4c33..23fe58fe067 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -6,7 +6,6 @@ import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { - AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' @@ -68,11 +67,14 @@ export type RuntimePtyController = { cwd?: string command?: string launchAgent?: TuiAgent + launchConfig?: WorktreeStartupLaunch['launchConfig'] + launchToken?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string @@ -115,17 +117,13 @@ export type RuntimePtyController = { } materialized?: true } - }): Promise<{ - id: string - pid?: number | null - incarnationId?: PtyIncarnationId - wslDistro?: string - stablePaneOwner?: { handle: string; tabId: string; leafId: string } - agentSessionEnsure?: AgentSessionClaimedSpawnResult - /** The host started this process at the workspace root because the requested folder is gone. */ - startupCwdFallback?: StartupCwdFallback - savedStartupCwd?: string - }> + }): Promise< + PtySpawnResult & { + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + startupCwdFallback?: StartupCwdFallback + savedStartupCwd?: string + } + > /** The local terminal owner's automatic-restore state; null when it has no fork gate. */ getAutomaticWorkStatus?(): Promise resumeAutomaticWork?(): Promise diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 0f840595f04..65b5f63c0cd 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -40,6 +40,8 @@ export type TerminalCreateOptions = { envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupAgent?: TuiAgent diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index cca1f51e750..b4841e2c8e1 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -1,6 +1,9 @@ import type { AgentStatus } from '../../shared/agent-detection' import type { AgentStatusState } from '../../shared/agent-status-types' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { + TerminalLaunchResult, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { RuntimeSyncedLeaf } from '../../shared/runtime-types' import type { RuntimeTerminalRowMetadata } from '../../shared/runtime-terminal-contracts' @@ -67,6 +70,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { paneKey?: string | null /** Window-owned L80 graphSequence when paneKey was recorded; newer surfaces have no graph statement that could contradict them (pty-recorded-surface-topology.ts). */ surfaceRecordedAtGraphSequence?: number + savedTerminalLaunchResult?: { + incarnationId: PtyIncarnationId | null + launchConfig: SleepingAgentLaunchConfig | null + launchToken: string | null + result: TerminalLaunchResult + } /** The ORCA_PANE_KEY the execution host reports this PTY's env exported; absent when unreported. */ spawnPaneKey?: string | null launchConfig: SleepingAgentLaunchConfig | null diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index 7c94942e852..77964ef047a 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -160,6 +160,7 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O launchAgent: request.agent, // Host-built resumes must name their session so readers can address it. resumeProviderSession: identity.providerSession, + ...(request.resumeOrigin ? { resumeOrigin: request.resumeOrigin } : {}), terminalImageViewerSupported: _caller.terminalImageViewerSupported, launchRuntime, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts index a9761c36ad3..3c7ac8662dd 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts @@ -41,6 +41,10 @@ describe('AgentSessionResumeClaims', () => { it('keeps a lost-reply attempt held unless a retry of the same request binds', () => { const { claims, exits } = makeClaims() claims.claim(SESSION, 'tab:a', 'request-1')?.settleFailure(true, new Error('reply lost')) + // Proof that this share dispatched no agent cannot account for the earlier lost reply. + claims.claim(SESSION, 'tab:a', 'request-1')?.settleFailure(false) + expect(exits.has('plain-shell')).toBe(false) + expect(claims.claim(SESSION, 'tab:b')).toBeNull() // Another request's spawn accounts only for itself. claims.claim(SESSION, 'tab:a', 'request-2')?.bind('pty-2') exits.get('pty-2')?.() diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts index 2a8a5ee4339..0c84b0a7e8e 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts @@ -26,7 +26,7 @@ type Hold = AgentSessionResumeHold & { export type AgentSessionResumeClaim = { /** The exact PTY this resumer's create produced; the hold ends once every such PTY exits. */ bind: (ptyId: string) => void - /** Releases when nothing was dispatched or the spawn proved its exit; otherwise stays held. */ + /** Releases on proof this share dispatched no claimed agent, or its spawn exited; otherwise held. */ settleFailure: (dispatched: boolean, error?: unknown) => void } diff --git a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts index 88e6a7e840e..27d8413978c 100644 --- a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts +++ b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts @@ -15,6 +15,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-session-contracts' import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types' +import type { RuntimeTerminalCreate } from '../../../../shared/runtime-terminal-create-contract' import { buildHeadlessMobileSessionTerminalTabs } from '../../mobile-session-terminal-projection' import { RestoredPaneStarter, @@ -27,6 +28,7 @@ import { reopenNewerDaemonTerminals } from '../../../daemon/daemon-newer-protoco import type { NewerDaemonReopenResult } from '../../../../shared/newer-daemon-hold' import type { WorkspaceLayoutStream } from '../../workspace-layout-stream' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import { worktreeIdsEqual } from '../../../../shared/worktree/id' export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs { @@ -215,6 +217,7 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu ...('resumeProviderSession' in agentStartup ? { resumeProviderSession: agentStartup.resumeProviderSession, + resumeOrigin: 'saved' as const, spawnIntent: automatic ? 'automatic-restore' : 'user-resume' } : {}), @@ -223,7 +226,30 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu onPtySpawnDispatched: () => { dispatched = true }, - ...(resumeClaim ? { onPtyCreated: resumeClaim.bind } : {}), + ...(resumeClaim + ? { + onPtyCreated: (ptyId: string, result: RuntimeTerminalCreate) => { + const decision = result.savedResumeDecision + if ( + !result.isReattach && + result.agentSessionDisposition !== 'adopted' && + decision && + decision.agent === resumeStartup?.launchAgent && + decision.verdict !== 'admitted' && + decision.verdict === result.agentResumeRefused && + agentProviderSessionsEqual( + decision.agent, + decision.providerSession, + resumeStartup?.resumeProviderSession + ) + ) { + resumeClaim.settleFailure(false) + } else { + resumeClaim.bind(ptyId) + } + } + } + : {}), ...(automatic ? { refuseSleptWorktree: true } : {}), // Only resumes from this pane's record show the window banner. ...(resumedRecord ? { resumesSleepingRecord: true as const } : {}) diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 0e89b6c3b53..5fe9dd7b78d 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -1,3 +1,8 @@ +import { hostSleepKey } from '../terminal/stop/worktree-terminal-sleep-key' +import { + terminalLaunchFacts, + type TerminalLaunchResult +} from '../../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' @@ -16,8 +21,41 @@ import { OPEN_CODE_WRITER_TOPOLOGY, cliWriterTopology } from '../../agent-hooks/server/cli-writer-observation' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption' +import { recordConversationKey } from '../../native-chat/structured-conversation-key' export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { + rememberSavedTerminalLaunchResult(ptyId: string, result: TerminalLaunchResult): void { + const pty = this.ptysById.get(ptyId) + if (pty && result.savedResumeDecision) { + pty.savedTerminalLaunchResult = { + incarnationId: pty.incarnationId, + launchConfig: pty.launchConfig, + launchToken: pty.launchToken, + result: terminalLaunchFacts(result) + } + } + } + + getTerminalLaunchResult(ptyId: string): TerminalLaunchResult | undefined { + const pty = this.ptysById.get(ptyId) + const saved = pty?.savedTerminalLaunchResult + if (!pty) { + return undefined + } + return terminalLaunchFacts({ + ...(saved?.incarnationId === pty.incarnationId && + saved.launchConfig === pty.launchConfig && + saved.launchToken === pty.launchToken + ? saved.result + : {}), + launchAgent: pty.launchAgent ?? undefined, + launchToken: pty.launchToken ?? undefined, + launchConfig: pty.launchConfig ?? undefined + }) + } + private cliWriterPtyPane(ptyId: string | null | undefined): CliWriterPane | null { const pty = ptyId ? this.ptysById.get(ptyId) : undefined return pty?.connected && !pty.connectionId && !pty.isWsl && pty.incarnationId @@ -54,6 +92,9 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca if (!agent || !binding || !pty) { return } + if (event.agentPresence?.process) { + delete pty.savedTerminalLaunchResult + } if (pty.cliConversations?.incarnationId !== pty.incarnationId) { pty.cliConversations = { incarnationId: pty.incarnationId, byKey: new Map() } } @@ -70,6 +111,20 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca }) } + /** Before a chat's agent spawns: refuses while a terminal may still write its head conversation. + * Never enters the session queue, so it can run inside an acquisition. */ + protected async assertStructuredChatAcquireAdmissible(record: AgentSessionRecord): Promise { + const head = record.providerHandleChain.at(-1) + const key = head ? recordConversationKey(record, head) : null + const adopts = Object.values(CLI_CONVERSATION_ADOPTING_AGENTS).some( + (agents: readonly string[]) => agents.includes(record.provider) + ) + if (!key?.id || !adopts) { + return + } + await this.assertStructuredAdoptionTerminalWritersExited(record.provider, key.id) + } + async assertStructuredAdoptionTerminalWritersExited( agent: StructuredAgentId, providerSessionId: string, @@ -105,12 +160,8 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca })) }), sleeping: (workspaceId) => - [...this.terminalSleepStateByHostWorktree.values()].some( - (state) => - state.worktreeId === workspaceId && - (state.phase === 'sleeping' || - state.phase === 'partial' || - state.phase === 'stopping') + this.terminalSleepStateByHostWorktree.has( + hostSleepKey(LOCAL_EXECUTION_HOST_ID, workspaceId) ), shellAlone: async (pane) => { if (process.platform === 'win32') { diff --git a/src/main/runtime/structured-agent-cli-writers.test.ts b/src/main/runtime/structured-agent-cli-writers.test.ts index d0bce827b70..d39ca3493b6 100644 --- a/src/main/runtime/structured-agent-cli-writers.test.ts +++ b/src/main/runtime/structured-agent-cli-writers.test.ts @@ -1,3 +1,4 @@ +import { hostSleepKey } from './terminal/stop/worktree-terminal-sleep-key' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { AgentProcessVerdict } from '../../shared/agent-process-presence' @@ -131,6 +132,196 @@ describe('execution-host conversation writer inspection', () => { } }) + it.each(['claude', 'codex', 'pi', 'opencode', 'opencode2'] as const)( + 'classifies the saved %s note separately from a physical terminal', + async (agent) => { + const check = inspection() + const record: SleepingAgentSessionRecord = { + paneKey: 'source', + worktreeId: 'workspace', + agent, + providerSession: { key: 'session_id', id: 'conversation' }, + prompt: '', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + check.host.saved = () => [record] + check.host.rows = () => [] + check.host.sleeping = () => true + check.host.terminal = () => null + const run = () => + assertCliConversationWritersExited( + check.host, + agent === 'opencode2' ? 'opencode' : agent, + 'conversation', + { paneKey: record.paneKey, worktreeId: record.worktreeId } + ) + await (agent === 'opencode' || agent === 'opencode2' + ? expect(run()).rejects.toMatchObject(refusal) + : expect(run()).resolves.toBeUndefined()) + check.host.terminal = () => ({ pane: null }) + await expect(run()).rejects.toMatchObject(refusal) + expect(check.host.shellAlone).not.toHaveBeenCalled() + } + ) + + it('uses normal physical proof for conclusively pane-owned OpenCode, retaining its saved-note guard', async () => { + const check = inspection() + check.host.rows = () => [{ ...row, agentType: 'opencode' }] + const saved: SleepingAgentSessionRecord = { + paneKey: row.paneKey, + worktreeId: 'workspace', + agent: 'opencode', + providerSession: { key: 'session_id', id: 'conversation' }, + prompt: '', + state: 'done', + origin: 'live', + capturedAt: 1, + updatedAt: 1 + } + check.host.saved = () => [saved] + const run = () => assertCliConversationWritersExited(check.host, 'opencode', 'conversation') + await expect(run()).resolves.toBeUndefined() + check.observation.presence = async () => 'live' + await expect(run()).rejects.toMatchObject(refusal) + }) + + it('probes a current row before admitting physical absence, even for a sleeping identity', async () => { + for (const verdict of [null, 'exited', 'live', 'unverifiable'] as const) { + const check = inspection(verdict) + check.host.terminal = () => null + check.host.sleeping = () => true + check.observation.topology = 'unverifiable' + const result = expect(check.run()) + await (verdict === null || verdict === 'exited' + ? result.resolves.toBeUndefined() + : result.rejects.toMatchObject(refusal)) + expect(check.observation.presence).toHaveBeenCalledOnce() + } + const replaced = inspection() + replaced.host.terminal = () => null + replaced.observation.presence = async () => { + replaced.replaceRow() + return null + } + await expect(replaced.run()).rejects.toMatchObject(refusal) + }) + + it('rechecks an absent saved pane and captured identity after another candidate awaits', async () => { + for (const changed of ['terminal', 'capture'] as const) { + const check = inspection() + const record: SleepingAgentSessionRecord = { + paneKey: 'absent', + worktreeId: 'workspace', + agent: 'codex', + providerSession: { key: 'session_id', id: 'conversation' }, + prompt: '', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + const other = { ...record, paneKey: 'other' } + check.host.saved = () => [record, other] + check.host.rows = () => [] + const terminal = check.host.terminal + let appeared = false + check.host.terminal = (coordinates) => + coordinates.paneKey === 'absent' && !appeared ? null : terminal(coordinates) + check.host.shellAlone = async () => { + if (changed === 'terminal') { + appeared = true + } else { + record.updatedAt += 1 + } + return true + } + await expect(check.run()).rejects.toMatchObject(refusal) + } + }) + + it('retains a matching absent row observation across another saved candidate await', async () => { + const check = inspection() + const record: SleepingAgentSessionRecord = { + paneKey: row.paneKey, + worktreeId: 'workspace', + agent: 'codex', + providerSession: { key: 'session_id', id: 'conversation' }, + prompt: '', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + const other = { ...record, paneKey: 'other' } + check.host.saved = () => [record, other] + const terminal = check.host.terminal + check.host.terminal = (coordinates) => + coordinates.paneKey === row.paneKey ? null : terminal(coordinates) + check.host.shellAlone = async () => { + check.replaceRow() + return true + } + await expect(check.run()).rejects.toMatchObject(refusal) + }) + + it.each(['absent', 'shell'] as const)( + 'retains %s current-row proof without a saved match across a later saved census', + async (physical) => { + for (const changed of ['unchanged', 'row', 'terminal'] as const) { + const check = inspection() + const other: SleepingAgentSessionRecord = { + paneKey: 'other', + worktreeId: 'workspace', + agent: 'codex', + providerSession: { key: 'session_id', id: 'conversation' }, + prompt: '', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + check.host.saved = () => [other] + const terminal = check.host.terminal + const otherTerminal = terminal(other) + let appeared = false + check.host.terminal = (coordinates) => + coordinates.paneKey !== row.paneKey + ? otherTerminal + : physical === 'absent' && !appeared + ? null + : terminal(coordinates) + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + let scans = 0 + check.host.shellAlone = async () => { + scans += 1 + if (physical === 'shell' && scans === 1) { + return true + } + entered.resolve() + return released.promise + } + const result = check.run() + const assertion = + changed === 'unchanged' + ? expect(result).resolves.toBeUndefined() + : expect(result).rejects.toMatchObject(refusal) + await entered.promise + if (changed === 'row') { + check.replaceRow() + } else if (changed === 'terminal') { + appeared = true + check.replacePty() + } + released.resolve(true) + await assertion + } + } + ) + it('requires current owned shell proof for saved resumes, including done checkpoints', async () => { const record: SleepingAgentSessionRecord = { paneKey: 'source', @@ -217,7 +408,10 @@ describe('execution-host conversation writer inspection', () => { const pane = `tab-a:${leaf}` let rows: AgentStatusIpcPayload[] = [] const store = ORCA_LAYOUT_OWNER_IS_MAIN ? writerLayoutStore() : null - const runtime = new OrcaRuntimeService(store, undefined, { + class WriterRuntime extends OrcaRuntimeService { + readonly sleepStates = this.terminalSleepStateByHostWorktree + } + const runtime = new WriterRuntime(store, undefined, { getAgentProviderSessionSnapshot: () => rows, readHookCliWriterObservation: () => ({ current: () => true, @@ -268,6 +462,24 @@ describe('execution-host conversation writer inspection', () => { } // The header switch inspects the pane it leaves even when nothing recorded the conversation. await census(() => check('claude', { workspaceId: 'workspace', paneKey: pane })) + // Another host's same-id workspace cannot hold this local source; each local sleep phase does. + const source = { workspaceId: 'workspace', paneKey: pane } + for (const phase of ['sleeping', 'partial', 'stopping'] as const) { + const state = { + worktreeId: 'workspace', + generation: 1, + phase, + ptyIds: [], + terminalHandles: [], + terminalHandlesByPtyId: {} + } + runtime.sleepStates.set(hostSleepKey('ssh:twin', 'workspace'), state) + await expect(check('claude', source)).resolves.toBeUndefined() + runtime.sleepStates.set(hostSleepKey('local', 'workspace'), state) + await expect(check('claude', source)).rejects.toMatchObject(refusal) + runtime.sleepStates.delete(hostSleepKey('local', 'workspace')) + } + runtime.sleepStates.clear() // A row restored after restart has no observed identity; the pane's current PTY decides. rows = [{ ...row, paneKey: pane, agentType: 'claude' }] await census(() => check('claude')) diff --git a/src/main/runtime/structured-agent-cli-writers.ts b/src/main/runtime/structured-agent-cli-writers.ts index 0cf3524279b..7435c86085f 100644 --- a/src/main/runtime/structured-agent-cli-writers.ts +++ b/src/main/runtime/structured-agent-cli-writers.ts @@ -74,6 +74,7 @@ export async function assertCliConversationWritersExited( chatAgentsShareConversations(rowChat, agent) ) && id === providerSessionId + const proofs: (() => boolean)[] = [] const paneOf = (coordinates: CliWriterCoordinates) => host.terminal(coordinates)?.pane ?? null // The pane's PTY itself, not a recorded pid: a wrapper pid can read exited beside a paused agent. const censusPane = async ( @@ -83,6 +84,7 @@ export async function assertCliConversationWritersExited( ) => { const terminal = read() if (!terminal && absentAdmits) { + proofs.push(() => !read()) return } const pane = terminal?.pane @@ -94,10 +96,11 @@ export async function assertCliConversationWritersExited( ) { throw refuse() } + proofs.push(() => !host.sleeping(worktreeId ?? '') && read()?.pane?.identity === pane.identity) } try { if (source) { - await censusPane(() => host.terminal(source), source.worktreeId, false) + await censusPane(() => host.terminal(source), source.worktreeId, true) } const savedCandidates = () => host @@ -143,25 +146,35 @@ export async function assertCliConversationWritersExited( record.providerSession.id === row.providerSession?.id const matching = saved.filter(matches) const observed = host.observation(row) - if ( - !observed || - !observed.current() || - observed.topology !== 'pane-owned' || - host.sleeping(row.worktreeId ?? '') - ) { + if (!observed || !observed.current()) { throw refuse() } + proofs.push(() => observed.current()) const pane = paneOf(row) const presence = await observed.presence() if ( !observed.current() || presence === 'live' || presence === 'unverifiable' || - (pane && paneOf(row)?.identity !== pane.identity) || - host.sleeping(row.worktreeId ?? '') + (pane && paneOf(row)?.identity !== pane.identity) ) { throw refuse() } + if (!host.terminal(row)) { + proofs.push(() => !host.terminal(row)) + if (row.agentType !== 'opencode' && row.agentType !== 'opencode2') { + for (const record of matching) { + covered.set( + record, + () => savedCurrent(record) && observed.current() && !host.terminal(row) + ) + } + } + continue + } + if (observed.topology !== 'pane-owned' || host.sleeping(row.worktreeId ?? '')) { + throw refuse() + } if (presence === 'exited' && matching.length === 0) { await censusPane(() => host.terminal(row), row.worktreeId, true) if (!observed.current()) { @@ -179,16 +192,11 @@ export async function assertCliConversationWritersExited( ) { throw refuse() } + const current = () => + paneOf(row)?.identity === pane.identity && !host.sleeping(row.worktreeId ?? '') + proofs.push(current) for (const record of matching) { - covered.set( - record, - () => - observed.current() && - paneOf(row)?.identity === pane.identity && - !host.sleeping(row.worktreeId ?? '') && - matches(record) && - host.saved().includes(record) - ) + covered.set(record, () => current() && savedCurrent(record) && matches(record)) } } for (const link of host.links()) { @@ -213,7 +221,12 @@ export async function assertCliConversationWritersExited( throw refuse() } const coordinates = { paneKey, worktreeId } - const pane = paneOf(coordinates) + const terminal = host.terminal(coordinates) + if (!terminal) { + covered.set(record, () => savedCurrent(record) && !host.terminal(coordinates)) + continue + } + const pane = terminal.pane if (!pane) { throw refuse() } @@ -228,6 +241,7 @@ export async function assertCliConversationWritersExited( covered.set(record, current) } if ( + proofs.some((current) => !current()) || [...saved, ...savedCandidates()].some( (record) => !savedCurrent(record) || !covered.get(record)?.() ) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 5df3b112f68..57b4a548be8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -3,7 +3,6 @@ import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { bindAtRestCommandContexts } from '../native-chat/agent-session-wire/at-rest-command-context' import type { StructuredAgentSessionTransportOverrides } from './structured-agent-session-transport-overrides' -import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import type { StructuredAttentionMobileDelivery } from './structured-agent-session-mobile-attention' @@ -30,8 +29,6 @@ import { releaseAgentSessionRecordStore, type OpenedAgentSessionRecordStore } from './agent-session-record-store-slot' -import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -55,22 +52,7 @@ import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support' import { migrateLegacyNativeChatVisualsFolders } from '../native-chat/native-chat-scratch-folder-migration' import { scheduleNativeChatVisualsSweep } from '../native-chat/native-chat-visuals-sweep' -/** Whether this profile holds a structured chat: a record or tab in the journal database. */ -export function hasPersistedStructuredAgentSessionStore( - stateDirectory: string, - fileExists: (path: string) => boolean = existsSync -): boolean { - const databasePath = journalDatabasePath(stateDirectory) - if (!fileExists(databasePath)) { - return false - } - try { - return journalDatabaseHoldsAgentSessions(databasePath) - } catch { - // A database that cannot be read cannot say it is empty. - return true - } -} +export { hasPersistedStructuredAgentSessionStore } from './agent-session-record-store-slot' export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & { stateDirectory: string @@ -109,6 +91,8 @@ export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportO prepareCodexCatalogProbeHome?: (homePath: string) => void /** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */ onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden'] + /** See `StructuredAgentSessionHostDeps.assertNoTerminalWriter`. */ + assertNoTerminalWriter?: StructuredAgentSessionHostDeps['assertNoTerminalWriter'] /** Host-owned phone delivery and reconciliation from the current journal projection. */ attentionDelivery?: StructuredAttentionMobileDelivery /** Resolve the account home launch would pin for catalog reads without a session; absent disables catalogs. */ @@ -278,6 +262,7 @@ async function installOnJournal( ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), ...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), + ...(deps.assertNoTerminalWriter ? { assertNoTerminalWriter: deps.assertNoTerminalWriter } : {}), ...(await modelCatalogHostDeps({ store, agents, diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts index 4ef29120ac4..bb8464200a5 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts @@ -1,6 +1,8 @@ -import { OrcaRuntimeWithCreateMobileSessionTerminal } from './mobile-terminal-create' +import { + OrcaRuntimeWithCreateMobileSessionTerminal, + type OwnedMobileTerminalCreateOptions +} from './mobile-terminal-create' import type { MobileTerminalCreateOptions } from './mobile-terminal-renderer-create' -import type { OwnedMobileTerminalCreateOptions } from './mobile-terminal-owned-create' import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' import { diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index a5ecece68cb..d47e68c8c11 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -8,11 +8,46 @@ import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' import type { RuntimeNavigationTarget } from '../../../../shared/runtime-navigation' -import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' +import type { + RuntimeTerminalCreate, + RuntimeMobileSessionCreateTerminalResult, + RuntimeTerminalPresentation +} from '../../../../shared/runtime-types' +import type { SpawnIntent } from '../../../../shared/host-spawn-admission' +import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' import { navigationTargetsHost } from '../../../../shared/runtime-navigation' import { MOBILE_TERMINAL_CREATE_RESULT_TTL_MS } from '../../orca-runtime-core' import { seedResumeLaunchIdentity } from '../../../ipc/pty/pane/resume-launch-identity' +export type OwnedMobileTerminalCreateOptions = { + command?: string + shellOverride?: string + cwd?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + identity?: { tabId: string; leafId: string; sessionId?: string } + createMutation?: { clientIdentity: string; id: string } + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + targetGroupId?: string + afterDesktopTabId?: string + presentation?: RuntimeTerminalPresentation + supportsSplitGroupPlacement?: boolean + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' + spawnIntent?: SpawnIntent + launchRuntime?: AgentLaunchRuntime + signal?: AbortSignal + refuseSleptWorktree?: boolean + resumesSleepingRecord?: true + /** See TerminalCreateOptions; a create that throws before it proves nothing spawned. */ + onPtySpawnDispatched?: () => void + /** The PTY this create produced, reported before publication can fail. */ + onPtyCreated?: (ptyId: string, result: RuntimeTerminalCreate) => void +} + export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRuntimeWithFailedStartPanes { protected abstract runCreateMobileSessionTerminal( worktreeSelector: string, diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 1733517a025..25ed6b5bad7 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -1,18 +1,12 @@ import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './mobile-terminal-startup' -import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' import type { TuiAgent } from '../../../../shared/tui-agent' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../../../../shared/agent-session-resume' -import type { SpawnIntent } from '../../../../shared/host-spawn-admission' -import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' import type { RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionTabsSnapshot, - RuntimeMobileSessionTerminalTab, - RuntimeTerminalPresentation + RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types' +import type { OwnedMobileTerminalCreateOptions } from './mobile-terminal-create' import { randomUUID } from 'node:crypto' import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../../remote-runtime-terminal-create-identity' @@ -27,34 +21,6 @@ import { const OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS = 15 * 60_000 -export type OwnedMobileTerminalCreateOptions = { - command?: string - shellOverride?: string - cwd?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - identity?: { tabId: string; leafId: string; sessionId?: string } - createMutation?: { clientIdentity: string; id: string } - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - targetGroupId?: string - afterDesktopTabId?: string - presentation?: RuntimeTerminalPresentation - supportsSplitGroupPlacement?: boolean - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - spawnIntent?: SpawnIntent - launchRuntime?: AgentLaunchRuntime - signal?: AbortSignal - refuseSleptWorktree?: boolean - resumesSleepingRecord?: true - /** See TerminalCreateOptions; a create that throws before it proves nothing spawned. */ - onPtySpawnDispatched?: () => void - /** The PTY this create produced, reported before publication can fail. */ - onPtyCreated?: (ptyId: string) => void -} - export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand { private readonly ownedMobileDispatchRecipes = new Map< string, @@ -132,6 +98,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ? { resumeProviderSession: opts.resumeProviderSession } : {}), ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), + ...(opts.resumeOrigin ? { resumeOrigin: opts.resumeOrigin } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), @@ -164,7 +131,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ) : await create() if (terminal.ptyId) { - opts.onPtyCreated?.(terminal.ptyId) + opts.onPtyCreated?.(terminal.ptyId, terminal) } const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { @@ -236,8 +203,8 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ...((terminal.savedStartupCwd ?? cwd) && (terminal.savedStartupCwd ?? cwd) !== workspace.path ? { startupCwd: terminal.savedStartupCwd ?? cwd } : {}), - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + ...(livePty.pty.launchAgent ? { launchAgent: livePty.pty.launchAgent } : {}), + ...(!terminal.agentResumeRefused && opts.viewMode ? { viewMode: opts.viewMode } : {}), parentLayout, isActive: activate } diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 8ac6e76af90..7a57eead259 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -1,3 +1,8 @@ +import { + recordTerminalLaunchMetadata, + resolveTerminalLaunchResult, + terminalLaunchPublicFacts +} from './terminal-launch-result' import { revealCreatedTerminal } from './terminal-create-reveal' import type { PaneSpawnOperation } from '../../../ipc/pty/pane/pane-spawn-operation' import { OrcaRuntimeWithTerminalCreateDeduplication } from './terminal-create-deduplication' @@ -124,6 +129,8 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi ? launchOpts.command : (agentTeamsPlan?.command ?? launchOpts.command), launchAgent: launchOpts.launchAgent, + launchConfig: effectiveLaunchConfig, + ...(launchToken ? { launchToken } : {}), commandDelivery: 'provider', startupCommandDelivery: launchOpts.startupCommandDelivery, env, @@ -132,6 +139,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi agentTeamsPlan?.envToDelete ), resumeProviderSession: launchOpts.resumeProviderSession, + ...(launchOpts.resumeOrigin ? { resumeOrigin: launchOpts.resumeOrigin } : {}), telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, @@ -195,6 +203,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi }) if (adoptedBeforeLaunch) { result = dependencies.materializedStablePaneReply(adoptedBeforeLaunch, preAllocatedHandle) + Object.assign(result, this.getTerminalLaunchResult(result.id)) } else if (!ptyController.adoptStablePane) { result = await spawnFresh({ mustExist: false }) } @@ -204,28 +213,18 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi restoreHint.consume() const savedStartupCwd = result.savedStartupCwd ?? cwd // Why here: refused before a handle, snapshot or reveal could pose the live PTY as new. - const adoptedStablePane = dependencies.admitStablePaneAdoption(result, launchOpts) - if (result.agentSessionEnsure) { - const canonicalSurface = result.agentSessionEnsure.owner.surface - preAllocatedHandle = canonicalSurface.terminalHandle - tabId = canonicalSurface.tabId - leafId = canonicalSurface.leafId - paneKey = dependencies.makePaneKey(tabId, leafId) - } else if (result.stablePaneOwner) { - preAllocatedHandle = result.stablePaneOwner.handle - tabId = result.stablePaneOwner.tabId - leafId = result.stablePaneOwner.leafId + const launch = resolveTerminalLaunchResult(result, { + ...launchOpts, + launchConfig: effectiveLaunchConfig, + launchToken + }) + if (launch.surface) { + ;({ terminalHandle: preAllocatedHandle, tabId, leafId } = launch.surface) paneKey = dependencies.makePaneKey(tabId, leafId) } - try { - this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId) - } catch (error) { - if (error instanceof Error && error.message === 'agent_session_exited_during_start') { - this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId) - } - throw error - } - if (!adoptedStablePane) { + this.assertCreatedPtyRegistrationAllowed(result.id, result.incarnationId) + // Provider reattachment still needs this runtime's handle and workspace binding. + if (!result.stablePaneOwner) { this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) @@ -240,29 +239,29 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { pty.runtimeSessionOwned = true - if (!adoptedStablePane) { - seedPtyLaunchTitle(pty, launchOpts.title, launchTitleAt, (title, at) => + if (!launch.adoptedStablePane) { + seedPtyLaunchTitle(pty, launch.effectiveTitle, launchTitleAt, (title, at) => this.setPtyManagementTitleFromObservedTitle(pty, title, at) ) - pty.launchConfig = effectiveLaunchConfig - ? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig) - : null - pty.launchToken = launchToken ?? null - pty.launchIncarnationId = launchToken ? pty.incarnationId : null - pty.launchAgent = launchOpts.launchAgent ?? null - pty.launchCommandAgent = resolveLaunchCommandPaneAgent(launchOpts.command) + } + recordTerminalLaunchMetadata(pty, result, launch) + if (!launch.adoptedStablePane) { + pty.launchCommandAgent = result.agentResumeRefused + ? null + : resolveLaunchCommandPaneAgent(launchOpts.command) + this.rememberSavedTerminalLaunchResult(result.id, result) } recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)) } const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle - if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { + if (pty && !launch.adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { tabId, leafId, - title: launchOpts.title ?? null, + title: launch.effectiveTitle ?? null, activate: presentation === 'focused', selectIfNoActiveTab: presentation !== 'background', - ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + ...(launch.effectiveViewMode ? { viewMode: launch.effectiveViewMode } : {}), ...(savedStartupCwd !== workspace.path ? { startupCwd: savedStartupCwd } : {}) }) } @@ -272,29 +271,31 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi ptyId: result.id, handle, cwd: savedStartupCwd, - launchOpts, - effectiveLaunchConfig, - launchToken, + launchOpts: { + ...launchOpts, + title: pty?.title ?? launch.effectiveTitle ?? undefined, + launchAgent: pty?.launchAgent ?? undefined, + viewMode: launch.effectiveViewMode + }, + effectiveLaunchConfig: pty?.launchConfig ?? undefined, + launchToken: pty?.launchToken ?? undefined, presentation, opts, tabId, leafId }) const { surface, warning } = reveal instanceof Promise ? await reveal : reveal - const agentSessionDisposition = result.agentSessionEnsure?.disposition return { + ...terminalLaunchPublicFacts(result, launch.adoptedStablePane, pty), handle, savedStartupCwd, tabId, paneKey, ptyId: result.id, worktreeId: workspace.id, - title: pty?.title ?? launchOpts.title ?? null, + title: pty?.title ?? launch.effectiveTitle ?? null, ...this.getPtyExecutionHostMetadata(result.id), surface, - ...(result.pid ? { processId: result.pid } : {}), - ...(result.agentSessionEnsure ? { agentSessionDisposition } : {}), - ...(adoptedStablePane ? { isReattach: true as const } : {}), ...(result.startupCwdFallback ? { startupCwdFallback: result.startupCwdFallback } : {}), ...(warning ? { warning } : {}) } diff --git a/src/main/runtime/terminal/launch/terminal-launch-result.ts b/src/main/runtime/terminal/launch/terminal-launch-result.ts new file mode 100644 index 00000000000..05a293f4d8a --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-launch-result.ts @@ -0,0 +1,86 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' +import { admitStablePaneAdoption } from '../../runtime-terminal-pane-identity' +import type { RuntimePtyController } from '../../runtime-pty-controller-contract' +import type { TerminalCreateOptions } from '../../runtime-terminal-contracts' +import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' +import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' + +type TerminalSpawnResult = Awaited>> + +export function resolveTerminalLaunchResult( + result: TerminalSpawnResult, + requested: Pick< + TerminalCreateOptions, + 'launchConfig' | 'launchAgent' | 'launchToken' | 'title' | 'viewMode' | 'requireFreshPane' + > +) { + const stablePaneAdopted = admitStablePaneAdoption(result, requested) + const adoptedStablePane = + stablePaneAdopted || + result.isReattach === true || + result.agentSessionEnsure?.disposition === 'adopted' + const refused = Boolean(result.agentResumeRefused) + const owner = result.stablePaneOwner + const surface = + result.agentSessionEnsure?.owner.surface ?? + (owner ? { terminalHandle: owner.handle, tabId: owner.tabId, leafId: owner.leafId } : undefined) + return { + adoptedStablePane, + surface, + launchAgent: refused + ? undefined + : (result.launchAgent ?? (adoptedStablePane ? undefined : requested.launchAgent)), + launchConfig: refused + ? undefined + : (result.launchConfig ?? (adoptedStablePane ? undefined : requested.launchConfig)), + launchToken: refused + ? undefined + : (result.launchToken ?? (adoptedStablePane ? undefined : requested.launchToken)), + effectiveTitle: refused || adoptedStablePane ? null : requested.title, + effectiveViewMode: refused || adoptedStablePane ? undefined : requested.viewMode + } +} + +export function recordTerminalLaunchMetadata( + pty: RuntimePtyWorktreeRecord, + result: TerminalSpawnResult, + launch: ReturnType +): void { + if (launch.adoptedStablePane) { + // A lower owner can supply facts before this runtime has a launch record. + if (!pty.launchConfig && result.launchConfig) { + pty.launchConfig = copySleepingAgentLaunchConfig(result.launchConfig) + } + pty.launchToken ??= result.launchToken ?? null + pty.launchAgent ??= result.launchAgent ?? null + if (pty.launchToken && !pty.launchIncarnationId) { + pty.launchIncarnationId = pty.incarnationId + } + } else { + pty.launchConfig = launch.launchConfig + ? copySleepingAgentLaunchConfig(launch.launchConfig) + : null + pty.launchToken = launch.launchToken ?? null + pty.launchIncarnationId = launch.launchToken ? pty.incarnationId : null + pty.launchAgent = launch.launchAgent ?? null + } +} + +export function terminalLaunchPublicFacts( + result: TerminalSpawnResult, + adoptedStablePane: boolean, + pty: RuntimePtyWorktreeRecord | null +) { + return { + ...terminalLaunchFacts(result), + launchConfig: pty?.launchConfig ?? undefined, + launchAgent: pty?.launchAgent ?? undefined, + launchToken: pty?.launchToken ?? undefined, + ...(!adoptedStablePane && result.coldRestore ? { coldRestore: result.coldRestore } : {}), + ...(result.pid ? { processId: result.pid } : {}), + ...(result.agentSessionEnsure + ? { agentSessionDisposition: result.agentSessionEnsure.disposition } + : {}), + ...(adoptedStablePane ? { isReattach: true as const } : {}) + } +} diff --git a/src/main/runtime/terminal/lifecycle/pty-registration.ts b/src/main/runtime/terminal/lifecycle/pty-registration.ts index bfd04c40ab3..f0349df5dc9 100644 --- a/src/main/runtime/terminal/lifecycle/pty-registration.ts +++ b/src/main/runtime/terminal/lifecycle/pty-registration.ts @@ -195,6 +195,20 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId) } + protected assertCreatedPtyRegistrationAllowed( + ptyId: string, + incarnationId?: PtyIncarnationId + ): void { + try { + this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId) + } catch (error) { + if (error instanceof Error && error.message === 'agent_session_exited_during_start') { + this.releaseRejectedPtyRegistrationFence(ptyId, incarnationId) + } + throw error + } + } + releaseRejectedPtyRegistrationFence( ptyId: string, candidateIncarnation?: PtyIncarnationId diff --git a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts index dd6046f9917..2c2c24170ea 100644 --- a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts +++ b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import type { OrcaRuntimeWithCreateTerminal } from '../launch/terminal-create' import { OrcaRuntimeWithRestoredHandlePaneStart } from '../surface/restored-handle-pane-start' import type { @@ -168,6 +169,7 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith leafId: parsed.leafId, focus: false }).then((terminal) => ({ + ...terminalLaunchFacts(terminal), handle: terminal.handle, tabId: parsed.tabId, leafId: parsed.leafId, diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts index 9aa19c35b55..286b1f84059 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts @@ -1,3 +1,4 @@ +import { sleepingRecordsEquivalentIgnoringCaptureTime } from '../../../../shared/agent-resume-record-equivalence' import { OrcaRuntimeWithStopTerminalsForWorktree } from './worktree-terminal-stop' import { applyWorktreeSleepResumeRecords, @@ -16,7 +17,11 @@ import type { AgentStatusClearIpcPayload, AgentStatusIpcPayload } from '../../../../shared/agent-status-ipc-payload' -import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { + agentProviderSessionsEqual, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { RefusedPaneResume } from '../../../../shared/agent-status-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import { parseExecutionHostId } from '../../../../shared/execution-host' import { parsePaneKey } from '../../../../shared/stable-pane-id' @@ -24,6 +29,66 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switc import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRuntimeWithStopTerminalsForWorktree { + /** Capture after earlier status writes so their saved copy participates in exact cleanup. */ + readSavedResumeSource( + worktreeId: string, + paneKey: string + ): Promise { + return this.inResumeRecordOrder( + async () => + this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) + ?.sleepingAgentSessionsByPaneKey?.[paneKey] + ) + } + + /** Forgets a pane's saved resume the host refused because a chat is its conversation's home, + * as a window does for its own panes; a host with no window has no one else to. */ + async forgetRefusedSavedResume( + worktreeId: string, + paneKey: string, + refused: RefusedPaneResume, + source?: SleepingAgentSessionRecord + ): Promise { + await this.inResumeRecordOrder(() => + this.commitWorkspaceSessionEdit( + this.getWorkspaceSessionHostIdForWorktree(worktreeId), + (session) => { + const saved = session.sleepingAgentSessionsByPaneKey?.[paneKey] + if ( + !source || + !refused.expectedSavedRecord || + saved?.paneKey !== refused.expectedSavedRecord.paneKey || + saved.capturedAt !== refused.expectedSavedRecord.capturedAt || + saved.updatedAt !== refused.expectedSavedRecord.updatedAt || + saved.connectionId !== source.connectionId || + !sleepingRecordsEquivalentIgnoringCaptureTime(saved, source) || + saved.agent !== refused.agent || + !agentProviderSessionsEqual( + refused.agent, + saved.providerSession, + refused.providerSession + ) + ) { + return null + } + const { [paneKey]: _refused, ...kept } = session.sleepingAgentSessionsByPaneKey ?? {} + return { ...session, sleepingAgentSessionsByPaneKey: kept } + } + ) + ) + const previous = this.previousHookStatusByPaneKey.get(paneKey) + if ( + previous && + refused.expectedStatus && + previous.updatedAt <= refused.expectedStatus.receivedAt && + previous.stateStartedAt === refused.expectedStatus.stateStartedAt && + previous.agentType === refused.agent && + agentProviderSessionsEqual(refused.agent, previous.providerSession, refused.providerSession) + ) { + this.previousHookStatusByPaneKey.delete(paneKey) + } + } + protected readonly sleepResumeRecords: WorktreeSleepResumeRecorder = { capture: (worktreeId, ptyIds) => captureWorktreeSleepResumeRecords({ @@ -58,7 +123,10 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu } /** Each pane's last hook status, which a status without a provider session inherits it from. */ - private readonly previousHookStatusByPaneKey = new Map() + private readonly previousHookStatusByPaneKey = new Map< + string, + ResumeRecordStatus & { stateStartedAt?: number } + >() private hookStatusResumeWrites: Promise = Promise.resolve() private readonly paneLaunchConfigOf: PaneLaunchConfigOf = (paneKey, agent) => { @@ -80,7 +148,10 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu if (!status) { return } - this.previousHookStatusByPaneKey.set(row.paneKey, status) + this.previousHookStatusByPaneKey.set(row.paneKey, { + ...status, + stateStartedAt: row.stateStartedAt + }) this.inResumeRecordOrder(() => this.writeHookStatusResume(status)).catch((error) => console.warn('[runtime] could not record an agent resume record', error) ) @@ -100,7 +171,19 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu forgetHookStatusResume(clear: AgentStatusClearIpcPayload): void { for (const [paneKey, status] of this.previousHookStatusByPaneKey) { if ( - 'paneKey' in clear ? paneKey === clear.paneKey : status.connectionId === clear.connectionId + 'paneKey' in clear + ? paneKey === clear.paneKey && + (!clear.refusedResume || + (!!clear.refusedResume.expectedStatus && + status.updatedAt <= clear.refusedResume.expectedStatus.receivedAt && + status.stateStartedAt === clear.refusedResume.expectedStatus.stateStartedAt && + status.agentType === clear.refusedResume.agent && + agentProviderSessionsEqual( + status.agentType, + status.providerSession, + clear.refusedResume.providerSession + ))) + : status.connectionId === clear.connectionId ) { this.previousHookStatusByPaneKey.delete(paneKey) } diff --git a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts index 55a45addcb9..4cc34eedb4b 100644 --- a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts +++ b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts @@ -5,6 +5,7 @@ import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout } from '../session import type { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from '../session-tabs/structured-session-create-support' import type { OrcaRuntimeWithGetPtyRecordForPaneKey } from '../orchestration/terminal-pane-mail-routing' import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from '../terminal/wait/terminal-wait-visible-probe' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { protected abstract listResolvedWorktreeSnapshot( @@ -42,6 +43,9 @@ export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeW abstract onStructuredSessionStatusForMail( ...args: Parameters ): ReturnType + protected abstract assertStructuredChatAcquireAdmissible( + record: AgentSessionRecord + ): Promise abstract isChatWorkerErrorEscalationEnabled(): boolean abstract onStructuredSessionTabHidden( ...args: Parameters diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 2fdd71f4440..031a44664aa 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -290,6 +290,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), conversationNaming: nameChat, onSessionTabHidden: (sessionId) => this.onStructuredSessionTabHidden(sessionId), + assertNoTerminalWriter: (record) => this.assertStructuredChatAcquireAdmissible(record), attentionDelivery: createStructuredAttentionMobileDelivery({ readNotificationSettings: () => this.requireStore().getSettings().notifications, readWorkspaceLabels: (scope) => diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 7f6777ad8e6..dbdaa7fd897 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -6,6 +6,8 @@ import type { import type { ResourceViewAdmission } from '../../shared/resource-view-admission' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { + SavedResumeDecision, + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' @@ -49,6 +51,7 @@ export type PtyApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -97,6 +100,11 @@ export type PtyApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' + savedResumeDecision?: SavedResumeDecision + refusedResume?: TerminalLaunchResult['refusedResume'] + launchToken?: string + resumeProviderSession?: TerminalLaunchResult['resumeProviderSession'] /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 1111d7610bd..14ba5066532 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -14,6 +14,8 @@ import type { TerminalLeafMoveResult } from '../../shared/terminal-leaf-move' import type { + SavedResumeDecision, + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' @@ -41,6 +43,7 @@ export const ptySessionControlApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -63,6 +66,7 @@ export const ptySessionControlApi = { id: string incarnationId?: string launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent snapshot?: string snapshotCols?: number snapshotRows?: number @@ -79,6 +83,11 @@ export const ptySessionControlApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' + savedResumeDecision?: SavedResumeDecision + refusedResume?: TerminalLaunchResult['refusedResume'] + launchToken?: string + resumeProviderSession?: TerminalLaunchResult['resumeProviderSession'] shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string, inputKind: TerminalInputKind): void => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx index b8c47c43746..26db3999303 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx @@ -228,6 +228,16 @@ afterEach(() => { }) } + it('renders the terminal refusal once beside the rejected message', async () => { + const fact: AgentSessionFailureFact = { kind: 'conversationOpenInTerminal' } + renderPane([rejected('terminal-held', 'Host fallback words.', fact)]) + const row = await notice('terminal-held') + expect(row.textContent).toContain( + 'Claude is already running this conversation in another terminal. Quit Claude there or close that terminal to continue here.' + ) + expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() + }) + // The start row owns the failure reason; resending a rejected message creates a new message. it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { mocks.journalItems = [startFailureRow(START_FAILED)] diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 75916328e7f..0b38825b0c7 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -89,6 +89,34 @@ afterEach(async () => { }) describe('desktop words for a failure fact', () => { + it.each(['Claude', 'Codex'])( + 'names %s in terminal refusals on every failure surface', + (agentName) => { + for (const surface of ['row', 'rejection', 'answer'] as const) { + expect( + agentSessionFailureSentence( + { kind: 'conversationOpenInTerminal' }, + surface, + { agentName }, + sayAgentSessionFailureTranslated + ) + ).toBe( + `${agentName} is already running this conversation in another terminal. Quit ${agentName} there or close that terminal to continue here.` + ) + } + expect( + agentSessionFailureSentence( + { kind: 'conversationOpenElsewhere' }, + 'row', + { agentName: 'Codex' }, + sayAgentSessionFailureTranslated + ) + ).toBe( + 'Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.' + ) + } + ) + it.each([ ['en', 'Then send your message again.'], ['es', 'Después, envía tu mensaje de nuevo.'], diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 4e031d4c209..b1214aaa200 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -149,6 +149,17 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.conversationOpenElsewhere', + COPY.conversationOpenElsewhere + ), + conversationOpenInTerminal: (values) => + translate( + 'components.native-chat.failureWords.conversationOpenInTerminal', + COPY.conversationOpenInTerminal, + values + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index f2f8d396285..7d70bc5eb56 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -173,7 +173,7 @@ describe('resumeAiVaultSessionInNewChat', () => { [ 'agent_session_conflict', 'conversationHeldElsewhere', - "Orca can't confirm Codex has exited in its terminal. Stop what's running there or close the terminal, then try again." + 'Codex is already running this conversation in another terminal. Quit Codex there or close that terminal, then open this conversation from Session History.' ], [ 'agent_session_identity_required', diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 2b2949f763f..1807c15f9c3 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -36,6 +36,14 @@ export async function reportCliChatResumeFailure( ) return } + // The chat's agent refused to start while the terminal may still write the conversation. + if ( + refusal?.code === 'agent_session_operation_invalid' && + refusal.details?.reason === 'conversationOpenInTerminal' + ) { + toast.error(cliConversationExitGuidance(source.agent, true)) + return + } if (refusal?.code === 'agent_session_identity_required') { if (refusal.details?.reason === 'transcriptNotFound') { toast.error( diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 646c30fab6b..4a7b5d3f965 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -276,8 +276,8 @@ describe('CLI identity resume through the existing launch owner', () => { }) await runCliChatResume(SOURCE, true) expect(mocks.toast).toHaveBeenCalledTimes(1) - expect(mocks.toast.mock.calls[0]?.[0]).toMatch( - /^Orca can't confirm Claude.* has exited in its terminal\. Stop what's running there or close the terminal, then open this conversation from Session History\.$/ + expect(mocks.toast.mock.calls[0]?.[0]).toBe( + 'Claude is already running this conversation in another terminal. Quit Claude there or close that terminal, then open this conversation from Session History.' ) expect(mocks.activate).not.toHaveBeenCalled() expect(tabs()).toEqual([]) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index ab48b74754c..0a00ea7b085 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { PtyConnectResult } from './pty-transport-types' @@ -7,8 +8,11 @@ export function projectIpcPtyConnectResult( spawnResult: IpcPtySpawnResult ): string | PtyConnectResult { const launchAgent = isTuiAgent(spawnResult.launchAgent) ? spawnResult.launchAgent : undefined + const facts = terminalLaunchFacts({ ...spawnResult, launchAgent }) if (spawnResult.isReattach || spawnResult.coldRestore || spawnResult.sessionExpired) { return { + ...facts, + ...(spawnResult.incarnationId ? { incarnationId: spawnResult.incarnationId } : {}), id: spawnResult.id, ...(spawnResult.isReattach ? { isReattach: true } : {}), ...(launchAgent ? { launchAgent } : {}), @@ -40,23 +44,34 @@ export function projectIpcPtyConnectResult( ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } if ( launchAgent || spawnResult.launchConfig || spawnResult.startupCwdFallback || - spawnResult.agentResumeUnavailable + spawnResult.agentResumeUnavailable || + spawnResult.savedResumeDecision || + spawnResult.refusedResume || + spawnResult.launchToken ) { return { + ...facts, + ...(spawnResult.incarnationId ? { incarnationId: spawnResult.incarnationId } : {}), id: spawnResult.id, ...(launchAgent ? { launchAgent } : {}), ...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}), ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } return spawnResult.id diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 8b4deb81563..e70d6cfc36f 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { readIpcErrorDetail } from '@/lib/ipc-error' @@ -132,7 +133,17 @@ export async function connectIpcPty( if (!context.isExpectedExitCurrent()) { return } - return { id: spawnResult.id, exitedBeforeAttach: true } + return { + ...terminalLaunchFacts(spawnResult), + id: spawnResult.id, + exitedBeforeAttach: true, + // Why kept: a shell that replaced a resume and then exited never resumed the agent, and a + // refused plan must still not be registered or consume the saved session. + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) + } } if (context.isDestroyed()) { return diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index ddb61c61ccc..eebe524441e 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -75,6 +75,7 @@ export async function spawnIpcPty( commandDelivery, launchConfig, resumeProviderSession, + resumeOrigin, launchToken, launchAgent, startupCommandDelivery, @@ -113,6 +114,9 @@ export async function spawnIpcPty( ...((connectOptions.resumeProviderSession ?? resumeProviderSession) ? { resumeProviderSession: connectOptions.resumeProviderSession ?? resumeProviderSession } : {}), + ...(resumesAgent && (connectOptions.resumeOrigin ?? resumeOrigin) + ? { resumeOrigin: connectOptions.resumeOrigin ?? resumeOrigin } + : {}), ...((connectOptions.launchToken ?? launchToken) ? { launchToken: connectOptions.launchToken ?? launchToken } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts index 768b275b136..927dbc21746 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts @@ -21,6 +21,8 @@ import { import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' +import type { AgentStatusSlice } from '@/store/slices/agent-status-slice-contract' +import { normalizeLaunchConfigRegistrationMetadata } from '@/store/slices/agent-status-launch-config' import type { MockTransport } from './pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -216,6 +218,17 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + const registerLaunchConfig: AgentStatusSlice['registerAgentLaunchConfig'] = ( + key, + config, + metadata + ) => { + mockStoreState.agentLaunchConfigByPaneKey[key] = { + launchConfig: config, + identity: normalizeLaunchConfigRegistrationMetadata(key, metadata) + } + } + mockStoreState.registerAgentLaunchConfig.mockImplementation(registerLaunchConfig) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts index c942c385d34..0fea01af354 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { + expectNoWindowStart, expectWindowPaintsColdRestoreOnly, mainLayoutApplyTransport } from './pty-connection-test-main-pane-start-fixtures' @@ -365,6 +366,9 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { agentCmdOverrides: {} }, agentStatusByPaneKey: {}, + consumeTabStartupCommand: vi.fn(), + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, sleepingAgentSessionsByPaneKey: { [paneKey]: { paneKey, @@ -378,7 +382,7 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { updatedAt: 1 } } - } as StoreState + } const pane = createPane(1) const manager = createManager(1) @@ -392,13 +396,20 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { await vi.advanceTimersByTimeAsync(70) if (ORCA_LAYOUT_OWNER_IS_MAIN) { - // Main resumes the agent; the window attaches, paints the host's history, and resumes nothing. - expectWindowPaintsColdRestoreOnly(pane, transport, deps, mockStoreState, 'lost-pty') - return + expectNoWindowStart(transport, mockStoreState) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'lost-pty', attachOnlyView: true }) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) + expect(transport.sendInput).not.toHaveBeenCalled() } expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable') + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index eda161456ba..c89990da68e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -102,6 +102,7 @@ export type StoreState = { removeDeferredSshSessionId: ReturnType consumePendingColdRestore: ReturnType consumePendingSnapshot: ReturnType + consumeTabStartupCommand?: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record retainedAgentsByPaneKey: Record diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 0968aaaffb0..deee26142a8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -11,6 +11,7 @@ import type { StartupCommandDelivery } from '../../../../shared/codex-startup-de import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { + SavedResumeStartupSource, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -29,6 +30,9 @@ export type PtyPaneStartup = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: a resume built from saved facts, which the host refuses for a chat's conversation. */ + resumeOrigin?: 'saved' + resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index 0cabd2f14ff..c6add42bf18 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -1,3 +1,7 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { POST_REPLAY_MODE_RESET, @@ -249,18 +253,37 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) - // Why: main started the pane and its agent; the window only paints it. - const preparedStartup = mainStartsPane(session) - ? null - : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) - const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (ctx.connectResult.agentResumeUnavailable) { - // Dropped resume argv starts a new session; the restored banner must not claim the old session returned. - session.showSessionRestoredBanner('resume-unavailable') - } else if (preparedStartup?.hasSleepingRecord) { - session.showSessionRestoredBanner() + // An existing owner permits history replay, not another saved continuation. + let resumeUnavailable = Boolean(ctx.connectResult.agentResumeUnavailable) + let preparedStartup = + mainStartsPane(session) || + ctx.connectResult.isReattach === true || + ctx.connectResult.agentResumeRefused || + resumeUnavailable + ? null + : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) + if (preparedStartup && !ctx.coldRestoreStartup) { + const rejected = applyTerminalLaunchResult( + session, + ctx.connectResult, + captureTerminalLaunchContext(session, preparedStartup), + 'candidate' + ) + if (rejected) { + resumeUnavailable = true + preparedStartup = null } + } + const didPrepareResume = + ctx.connectResult.isReattach !== true && + session.applyColdRestoreAgentResumeStartup(preparedStartup) + if (resumeUnavailable) { + // Dropped resume argv starts a new session; the restored banner must not claim the old session returned. + session.showSessionRestoredBanner('resume-unavailable') + } else if (didPrepareResume && preparedStartup?.hasSleepingRecord) { + session.showSessionRestoredBanner() + } + if (didPrepareResume) { session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) } // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts index 1ca88396138..e983d9b56c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -16,10 +16,13 @@ import { normalizeAgentProviderSession } from '../../../../../shared/agent-session-resume' +import type { HeldPaneReopenOutcome } from '../newer-daemon-hold-state' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' +export const NOT_REOPENED: HeldPaneReopenOutcome = { reopened: false, agentResumed: false } + type AppState = ReturnType /** The agent session a cold restore of this pane would resume; read-only, so pre-spawn checks can share it. */ @@ -109,6 +112,24 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe }, launchConfig: startupPlan.launchConfig, resumeProviderSession: providerSession, + resumeOrigin: 'saved', + resumeSource: { + cancelled: false, + ...(sleepingRecord && + sleepingRecord.agent === agent && + agentProviderSessionsEqual(agent, sleepingRecord.providerSession, providerSession) + ? { record: sleepingRecord } + : {}), + ...(useLiveEntry && entry + ? { + expectedStatus: { + paneKey: session.cacheKey, + receivedAt: entry.updatedAt, + stateStartedAt: entry.stateStartedAt + } + } + : {}) + }, launchToken: coldRestoreLaunchToken, useLiveEntry: Boolean(useLiveEntry), hasSleepingRecord: Boolean(sleepingRecord), @@ -116,3 +137,26 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe } } } + +/** Keeps the reattach fallback's actual fresh-agent outcome with the pane's start. */ +export function reopenPaneAfterMissingReattach( + session: ConnectPanePtySession, + startup: ColdRestoreAgentResumeStartup | null, + resumedAgent: (ptyId: string) => boolean +): Promise { + let resumeUnavailable = false + return session + .startFreshColdRestoreAgentResume(startup, { + forceBlankRestoredViewport: true, + onAgentResumeUnavailable: () => { + resumeUnavailable = true + } + }) + .then( + (ptyId: string | null) => ({ + reopened: ptyId !== null, + agentResumed: ptyId !== null && resumedAgent(ptyId) && !resumeUnavailable + }), + () => NOT_REOPENED + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 2aa5c122870..9cc95ec6fd7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -133,6 +133,9 @@ export function connectPanePty( session.waitTeardowns = [] // Consume startup once from this shallow-copied deps; callers must clear their separate outer reference. session.paneStartup = session.deps.startup ?? null + if (session.paneStartup?.resumeOrigin === 'saved') { + session.paneStartup.resumeSource ??= { cancelled: false } + } session.deps.startup = undefined // Hold replacement PTY retirement until main takes it in a spawn request or disposal kills it. session.pendingReplacedPtyId = session.deps.replacesPtyId ?? null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 713c717c494..593f10738d6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -28,16 +28,28 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio session.applyColdRestoreAgentResumeStartup = ( startup: ColdRestoreAgentResumeStartup | null ): boolean => { - if (!startup) { + if (!startup || startup.resumeSource?.cancelled) { return false } const state = useAppStore.getState() + if (!session.pendingStartupCommand && session.shouldDeliverStartupViaTerminalPaste) { + session.pendingStartupCommand = { + command: startup.command, + resumeSource: startup.resumeSource + } + } state.registerAgentLaunchConfig(session.cacheKey, startup.launchConfig, { agentType: startup.agent, launchToken: startup.launchToken, tabId: session.deps.tabId, leafId: session.pane.leafId }) + if (startup.resumeSource) { + session.savedStartupLaunchRegistration = { + source: startup.resumeSource, + entry: useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + } + } return true } session.clearSleepingRecordAfterColdRestoreSpawn = ( @@ -75,6 +87,9 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(), options: FreshSpawnOptions = {} ): Promise => { + if (startup?.resumeSource?.cancelled) { + return Promise.resolve(null) + } const mainPane = mainResumedSleepPane(session, startup) if (mainPane && startup?.sleepingRecordEntry) { return attachToMainResumedSleepPane( @@ -107,7 +122,10 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio !session.disposed && session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && session.transport.getPtyId() === ptyId - const runTerminalPasteStartupCommand = async (command: string): Promise => { + const runTerminalPasteStartupCommand = async ( + command: string, + current: () => boolean + ): Promise => { const ptyId = session.transport.getPtyId() const result = await executeTerminalStartupCommandPaste({ command, @@ -120,43 +138,52 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio isWindowsConpty: session.isNativeWindowsConpty }), transport: session.transport, - isTargetCurrent: isStartupPasteTargetCurrent + isTargetCurrent: (target) => current() && isStartupPasteTargetCurrent(target) }) - if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { + if (result.status !== 'pasted' || !current() || !isStartupPasteTargetCurrent(ptyId)) { return false } return session.transport.sendInput('\r', 'launch') } session.schedulePendingStartupCommandDelivery = (): void => { const startup = session.pendingStartupCommand - if (!startup) { + if (!startup || startup.resumeSource?.cancelled) { return } if (session.startupInjectTimer !== null) { clearTimeout(session.startupInjectTimer) } + const generation = session.transportStreamGeneration + const isCurrent = (): boolean => + !session.disposed && + session.pendingStartupCommand === startup && + !startup.resumeSource?.cancelled && + generation === session.transportStreamGeneration && + (startup.isCurrent?.() ?? true) session.startupInjectTimer = setTimeout(() => { session.startupInjectTimer = null void (async () => { - if (session.pendingStartupCommand !== startup || session.disposed) { + if (!isCurrent()) { return } if (session.shouldDeliverStartupViaTerminalPaste) { await waitForTerminalOutputParsed(session.pane.terminal) } - if (session.pendingStartupCommand !== startup || session.disposed) { + if (!isCurrent()) { return } const command = startup.command const submitted = session.shouldDeliverStartupViaTerminalPaste - ? await runTerminalPasteStartupCommand(command) + ? await runTerminalPasteStartupCommand(command, isCurrent) : session.transport.sendInput(`${command}\r`, 'launch') if (submitted) { session.armStartupDraftReadinessObservation() } else { session.releaseUnattemptedStartupDraftPasteDelivery() } - session.pendingStartupCommand = null + if (session.pendingStartupCommand === startup) { + session.pendingStartupCommand = null + } })() }, 50) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts index e844f90a9f0..ef0d999be5e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -1,3 +1,4 @@ +import { NOT_REOPENED, reopenPaneAfterMissingReattach } from './cold-restore-resume-startup' import { writeStartupCwdFallbackNotice } from './startup-cwd-fallback-notice' import { armPaneOwnerRetry } from './pane-owner-recovery' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' @@ -65,8 +66,6 @@ async function prepareDeferredSessionReattach( void connectDeferredSessionReattach(session, deferredReattachSessionId, options) } -const NOT_REOPENED: HeldPaneReopenOutcome = { reopened: false, agentResumed: false } - /** Resolves to what the reattach did, which a held pane's release reports. */ function connectDeferredSessionReattach( session: ConnectPanePtySession, @@ -119,7 +118,8 @@ function connectDeferredSessionReattach( session.reportError(message) }, // Why the resume even under main: it is what main ran, so a capacity retry resumes it again. - toProcessExitStartup(resumeStartup ?? session.paneStartup) + toProcessExitStartup(resumeStartup ?? session.paneStartup), + resumeStartup ?? session.paneStartup ) session.beginReattachLiveDataDeferral(outputCallbacks.generation) session.transportConnectInFlightSince = Date.now() @@ -136,6 +136,7 @@ function connectDeferredSessionReattach( ...(coldRestoreStartup?.resumeProviderSession ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } : {}), + ...(coldRestoreStartup ? { resumeOrigin: coldRestoreStartup.resumeOrigin } : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), @@ -147,15 +148,7 @@ function connectDeferredSessionReattach( session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && outputCallbacks.generation === session.transportStreamGeneration const startFreshInstead = (): Promise => - session - .startFreshColdRestoreAgentResume(coldRestoreStartup, { forceBlankRestoredViewport: true }) - .then( - (ptyId: string | null) => ({ - reopened: ptyId !== null, - agentResumed: ptyId !== null && resumedAgent(ptyId) - }), - () => NOT_REOPENED - ) + reopenPaneAfterMissingReattach(session, coldRestoreStartup, resumedAgent) void Promise.resolve(reattachPromise) .catch(() => null) @@ -242,7 +235,8 @@ function connectDeferredSessionReattach( result, deferredReattachSessionId, coldRestoreStartup, - outputCallbacks.generation + outputCallbacks.generation, + outputCallbacks.launchContext ) if (accepted && isCurrentReattach()) { writeStartupCwdFallbackNotice(session, result) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts index 013b6a30723..7a2a09ad6f1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts @@ -7,6 +7,7 @@ import { NEWER_DAEMON_HOLD_ERROR } from '../../../../../shared/newer-daemon-hold import { newerDaemonHoldStore, reopenHeldTerminals } from '../newer-daemon-hold-state' import { startDeferredSessionReattach } from './deferred-session-reattach-connect' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import type { FreshSpawnOptions } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { PANE_NOT_LIVE_ERROR } from '../../../../../shared/pane-attach-only-view' import { useAppStore } from '@/store' @@ -64,7 +65,9 @@ function createSession(opts: { runtimeEnvironmentId?: string } = {}) { mergeStartupEnvWithPaneIdentity: (env: Record) => env, handleReattachResult: vi.fn().mockResolvedValue(true), clearExitedPanePtyLayoutBinding: vi.fn(), - startFreshColdRestoreAgentResume: vi.fn().mockResolvedValue('fresh-pty'), + startFreshColdRestoreAgentResume: vi + .fn<(startup: unknown, options?: FreshSpawnOptions) => Promise>() + .mockResolvedValue('fresh-pty'), reportError: vi.fn(), cols: 80, rows: 24, @@ -154,15 +157,27 @@ describe('restored pane refused for a newer daemon', () => { expect(fixture.writeReplayData).not.toHaveBeenCalled() }) - it('counts a released pane that falls back to a fresh session as reopened', async () => { - const { fixture, session, nextConnect } = createSession() - startDeferredSessionReattach(session, 'restored-pty') - await vi.waitFor(() => expect(isHeld()).toBe(true)) - nextConnect('throw') - await reopenHeldTerminals('tab:leaf') - expect(fixture.startFreshColdRestoreAgentResume).toHaveBeenCalledOnce() - expect(toast.success).toHaveBeenCalledWith('Reopened 1 terminal', windowResumedDescription) - }) + it.each([false, true])( + 'counts the fallback terminal without claiming an unavailable resume (%s)', + async (unavailable) => { + const { fixture, session, nextConnect } = createSession() + startDeferredSessionReattach(session, 'restored-pty') + await vi.waitFor(() => expect(isHeld()).toBe(true)) + fixture.startFreshColdRestoreAgentResume.mockImplementation(async (_startup, options) => { + if (unavailable) { + options?.onAgentResumeUnavailable?.() + } + return 'fresh-pty' + }) + nextConnect('throw') + await reopenHeldTerminals('tab:leaf') + expect(fixture.startFreshColdRestoreAgentResume).toHaveBeenCalledOnce() + expect(toast.success).toHaveBeenCalledWith( + 'Reopened 1 terminal', + unavailable ? undefined : windowResumedDescription + ) + } + ) it.each([ ['restored', { description: '1 agent session resumed.' }], diff --git a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts index 3c44157a66e..dc96b8fdb77 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts @@ -25,6 +25,7 @@ function resumeStartup( return { command: 'codex resume conv-1', agent: 'codex', + resumeOrigin: 'saved', resumeProviderSession: { key: 'session_id', id: 'conv-1' }, launchConfig: { agentArgs: '', agentEnv: {} }, launchToken: 'token-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index e658dba14eb..2abd01112ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -1,3 +1,4 @@ +import { applyTerminalLaunchResult } from './terminal-launch-result-policy' import { armPaneOwnerRetry, isPaneOwnerRefusal } from './pane-owner-recovery' import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' @@ -47,6 +48,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { releaseDeferredCwdFence() return Promise.resolve(null) } + const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride + if (effectiveStartup?.resumeSource?.cancelled) { + releaseDeferredCwdFence() + return Promise.resolve(null) + } session.authoritativeReattachGeneration += 1 // Every connect creates/rebinds ownership; legacy replies lacking incarnationId cannot inherit the old fence. session.remotePtyIncarnationId = null @@ -77,14 +83,14 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.transportConnectInFlightSince = Date.now() session.pendingSpawnDeferral = null const visibleAtSpawn = session.deps.isVisibleRef.current === true - const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride let ownerRefused = false const outputCallbacks = session.captureTransportOutputCallbacks( (message: string) => { ownerRefused ||= isPaneOwnerRefusal(message) session.reportError(message) }, - toProcessExitStartup(coldRestoreOverride ?? effectiveStartup) + toProcessExitStartup(coldRestoreOverride ?? effectiveStartup), + coldRestoreOverride ?? effectiveStartup ) const spawnedRaw = session.transport.connect({ url: '', @@ -96,7 +102,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { : {}), ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), ...(coldRestoreOverride - ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } + ? { + resumeProviderSession: coldRestoreOverride.resumeProviderSession, + resumeOrigin: coldRestoreOverride.resumeOrigin + } : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), @@ -149,12 +158,19 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // Legacy replies may omit incarnationId; remote evidence remains unverifiable until a stamped attach result. session.remotePtyIncarnationId = connectResult?.incarnationId ?? null if (connectResult?.isReattach) { - session.pendingStartupCommand = null + if ( + session.pendingStartupCommand?.resumeSource === effectiveStartup?.resumeSource && + (effectiveStartup?.resumeSource || + session.pendingStartupCommand?.command === effectiveStartup?.command) + ) { + session.pendingStartupCommand = null + } const accepted = await session.handleReattachResult( connectResult, null, coldRestoreOverride, - outputCallbacks.generation + outputCallbacks.generation, + outputCallbacks.launchContext ) session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise @@ -169,11 +185,20 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } return accepted ? resolvedPtyId : null } - if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { - session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) - }) + const rejectedSavedStartup = connectResult + ? applyTerminalLaunchResult( + session, + connectResult, + outputCallbacks.launchContext, + 'creator' + ) + : false + const coldRestore = rejectedSavedStartup ? null : coldRestoreOverride + if ( + connectResult?.agentResumeRefused === 'unverifiable' && + !(coldRestoreOverride ?? effectiveStartup)?.resumeSource?.record + ) { + session.keepUnrunResumeHandle(startupOverride) } if (resolvedPtyId) { if ( @@ -191,17 +216,17 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { spawnedPtyId.agentResumeUnavailable ) { // Dropped resume argv starts a new session; do not claim the previous session was restored. - session.showSessionRestoredBanner('resume-unavailable') - } else if (coldRestoreOverride?.hasSleepingRecord) { + options.onAgentResumeUnavailable?.() + } else if (coldRestore?.hasSleepingRecord) { session.showSessionRestoredBanner() } - session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + session.clearSleepingRecordAfterColdRestoreSpawn(coldRestore) } else if ( session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) ) { // Unbound spawns are no delivery target; clear launch registration so delayed drafts cannot reach a future pane. - session.clearRegisteredStartupLaunchConfig() + session.clearRegisteredStartupLaunchConfig(coldRestoreOverride ?? effectiveStartup) } if ( resolvedPtyId && @@ -250,7 +275,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) ) { - session.clearRegisteredStartupLaunchConfig() + session.clearRegisteredStartupLaunchConfig(coldRestoreOverride ?? effectiveStartup) } clearPreSignaledSerializer() return null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts index 4bb06f8dbde..f360309d461 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts @@ -1,22 +1,29 @@ import type { AgentProviderSessionMetadata, ResumableTuiAgent, + SavedResumeStartupSource, SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' export type PendingStartupCommand = { command: string + resumeSource?: SavedResumeStartupSource + isCurrent?: () => boolean env?: Record } export type FreshSpawnOptions = { forceBlankRestoredViewport?: boolean + /** Main started the pane without resuming the requested agent session. */ + onAgentResumeUnavailable?: () => void } export type ColdRestoreAgentResumeStartup = PendingStartupCommand & { agent: ResumableTuiAgent resumeProviderSession: AgentProviderSessionMetadata + /** Built from the pane's saved facts, so the host refuses it for a chat's conversation. */ + resumeOrigin: 'saved' launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts index d02ff702baa..b6bf9f7b2ff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts @@ -32,6 +32,9 @@ export function deferHiddenAgentResume( ptyId: session.transport.getPtyId(), record: recordEntry.record, wake: (): Promise => { + if (startup?.resumeSource?.cancelled) { + return Promise.resolve(null) + } session.hiddenAgentResumeReleased = true return start().then((ptyId) => { // Why the transport: a held reattach resolves before it binds, and the hold must cover its fallback. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index a5e016e3742..5acf2175828 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -59,9 +59,10 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { // Providers own startup delivery across renderer replacement; only explicit terminal paste stays renderer-owned. session.pendingStartupCommand = session.shouldDeliverStartupViaTerminalPaste ? session.paneStartup?.command - ? { command: session.paneStartup.command } + ? { command: session.paneStartup.command, resumeSource: session.paneStartup.resumeSource } : null : null + const startupSource = session.paneStartup?.resumeSource const startupDraftReadyScanner = session.ownsStartupDraftPaste ? createDraftPasteReadyScanner( session.startupDraftAgentConfig?.draftPasteReadySignal ?? @@ -89,6 +90,7 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { const ptyId = session.transport.getPtyId() if ( !ptyId || + startupSource?.cancelled || session.disposed || session.deps.paneTransportsRef.current.get(session.pane.id) !== session.transport ) { @@ -115,6 +117,9 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { session.cleanupStartupDraftPasteTimers() // Share the transport queue with focus replies so ConPTY cannot race CSI I into the draft as a literal prefix. void sendAgentDraftPasteContent(ptyId, session.startupDraftPrompt, 'launch', async (data) => { + if (getStartupDraftPtyId() !== ptyId) { + return false + } const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') if (accepted && !startupDraftInputRecorded) { // Transport paste bypasses xterm’s input signal; record it so hibernation cannot discard the composed draft. @@ -155,6 +160,9 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { } startupDraftHardTimer = setTimeout(() => { startupDraftHardTimer = null + if (!getStartupDraftPtyId()) { + return + } if (session.startupDraftAgent === 'codex') { startupDraftPasteSettled = true // A timed-out launch stays consumed across pane disposal and remount. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts index 70ccdf7ab61..e32d966d1d6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -81,6 +81,9 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt ...(session.paneStartup?.resumeProviderSession ? { resumeProviderSession: session.paneStartup.resumeProviderSession } : {}), + ...(session.paneStartup?.resumeOrigin + ? { resumeOrigin: session.paneStartup.resumeOrigin } + : {}), ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) ? { agentPrompt: diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index cbf1ede5197..45735331642 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' import { createPane } from '../pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -6,6 +7,7 @@ import { } from '../pty-connection-test-environment' import { createReattachPayloadHandlers } from './apply-reattach-payload' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { mainStartsPane } from './main-starts-pane' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { ReattachPayloadContext } from './reattach-payload-context' import type { ReattachPayloadSession } from './reattach-payload-session' @@ -83,7 +85,7 @@ describe('reattach payload context', () => { sleepingRecordEntry: null } as ColdRestoreAgentResumeStartup const buildColdRestoreAgentResumeStartup = vi.fn() - const applyColdRestoreAgentResumeStartup = vi.fn(() => true) + const applyColdRestoreAgentResumeStartup = vi.fn((value) => Boolean(value)) const schedulePendingStartupCommandDelivery = vi.fn() const session = createSession({ buildColdRestoreAgentResumeStartup, @@ -107,7 +109,9 @@ describe('reattach payload context', () => { if (ORCA_LAYOUT_OWNER_IS_MAIN) { // Main started the pane and resumed its agent; the window's paint applies no resume. - expect(applyColdRestoreAgentResumeStartup).not.toHaveBeenCalledWith(startup) + expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledExactlyOnceWith(null) + expect(session.clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() + expect(session.showSessionRestoredBanner).not.toHaveBeenCalled() } else { expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(startup) } @@ -116,3 +120,147 @@ describe('reattach payload context', () => { expect(context.reattachPayloadApplied).toBe(true) }) }) + +it.each(['missing', 'different', 'admitted'] as const)( + 'authorizes only the checked late saved candidate (%s)', + async (kind) => { + await installTerminalTestGlobals() + try { + const startup: ColdRestoreAgentResumeStartup = { + command: 'codex resume C', + agent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: 'C' }, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + launchToken: 'saved-C', + resumeSource: { cancelled: false }, + useLiveEntry: false, + hasSleepingRecord: false, + sleepingRecordEntry: null + } + const apply = vi.fn((value) => Boolean(value)) + const cancel = vi.fn() + const schedule = vi.fn() + const session = createSession({ + // Main can hand a twin's start back to this renderer-owned launch path. + mainPaneStartHandedBack: true, + buildColdRestoreAgentResumeStartup: () => startup, + applyColdRestoreAgentResumeStartup: apply, + cancelSavedResumeStartup: cancel, + schedulePendingStartupCommandDelivery: schedule, + clearSleepingRecordAfterColdRestoreSpawn: vi.fn(), + showSessionRestoredBanner: vi.fn(), + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + expect(mainStartsPane(session)).toBe(false) + const context = createContext('', 1) + context.connectResult = { + id: 'pty-1', + coldRestore: { scrollback: '', cwd: '/workspace' }, + ...(kind === 'missing' + ? {} + : { + savedResumeDecision: { + agent: 'codex', + providerSession: { key: 'session_id', id: kind === 'different' ? 'D' : 'C' }, + verdict: 'admitted' + } + }) + } + await createReattachPayloadHandlers(session, context).applyReattachPayload() + expect(apply).toHaveBeenCalledWith(kind === 'admitted' ? startup : null) + expect(schedule).toHaveBeenCalledTimes(kind === 'admitted' ? 1 : 0) + expect(cancel).toHaveBeenCalledTimes(kind === 'admitted' ? 0 : 1) + } finally { + await restoreTerminalTestGlobals() + } + } +) + +it.each(['missing', 'admitted', 'prepared'] as const)( + 'observes a cached cold owner without consuming an uncorrelated newer recipe (%s)', + async (kind) => { + await installTerminalTestGlobals() + const record = { + paneKey: 'tab:leaf', + worktreeId: 'wt', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + state: 'done' as const, + prompt: '', + capturedAt: 20, + updatedAt: 20 + } + const previousRecords = useAppStore.getState().sleepingAgentSessionsByPaneKey + useAppStore.setState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }) + try { + const startup: ColdRestoreAgentResumeStartup = { + command: 'codex resume C', + agent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: record.providerSession, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + launchToken: 'replacement-token', + useLiveEntry: false, + hasSleepingRecord: true, + sleepingRecordEntry: { paneKey: record.paneKey, record } + } + const session = createSession({ + cacheKey: record.paneKey, + buildColdRestoreAgentResumeStartup: vi.fn(() => startup), + applyColdRestoreAgentResumeStartup: vi.fn(() => true), + cancelSavedResumeStartup: vi.fn(), + schedulePendingStartupCommandDelivery: vi.fn(), + clearSleepingRecordAfterColdRestoreSpawn: vi.fn(), + showSessionRestoredBanner: vi.fn(), + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + const context: ReattachPayloadContext = { + ...createContext('', 1), + connectResult: { + id: 'pty-1', + isReattach: true, + coldRestore: { scrollback: 'cached history', cwd: '/workspace' }, + ...(kind === 'admitted' + ? { + savedResumeDecision: { + agent: 'codex', + providerSession: record.providerSession, + verdict: 'admitted' + } + } + : {}) + }, + coldRestoreStartup: kind === 'prepared' ? startup : null + } + await createReattachPayloadHandlers(session, context).applyReattachPayload() + expect(session.writeReplayData).toHaveBeenCalledWith( + expect.stringContaining('cached history') + ) + expect(window.api.pty.ackColdRestore).toHaveBeenCalledOnce() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('pty-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: session.pane.leafId + }) + } else { + expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('pty-1') + } + expect(context.reattachPayloadApplied).toBe(true) + expect(session.buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(session.applyColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(session.cancelSavedResumeStartup).not.toHaveBeenCalled() + expect(session.schedulePendingStartupCommandDelivery).not.toHaveBeenCalled() + expect(session.clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() + expect(session.showSessionRestoredBanner).not.toHaveBeenCalled() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + expect(window.api.pty.write).not.toHaveBeenCalled() + } finally { + useAppStore.setState({ sleepingAgentSessionsByPaneKey: previousRecords }) + await restoreTerminalTestGlobals() + } + } +) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index 7f30f25f99f..88ff91e770a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -2,6 +2,9 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' export type ReattachPayloadSession = Pick< ConnectPanePtySession, + | 'cacheKey' + | 'paneStartup' + | 'registerEffectiveLaunchConfig' | 'applyColdRestoreAgentResumeStartup' | 'applySnapshotKittyKeyboardModes' | 'buildColdRestoreAgentResumeStartup' @@ -18,6 +21,7 @@ export type ReattachPayloadSession = Pick< | 'recordRendererOrderedSeq' | 'rememberReattachPayloadAgentSignal' | 'schedulePendingStartupCommandDelivery' + | 'cancelSavedResumeStartup' | 'sendFocusedReattachFocusInAfterReplay' | 'setRestoredSnapshotBaseline' | 'shouldPreserveAgentReattachModes' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts index 0597d1c45ac..c83223c5b15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts @@ -12,7 +12,12 @@ const REMOTE_PTY_ID = 'remote:env-1@@pty-1' const HOST_IMAGE = { data: 'PROMPT $ ', cols: 80, rows: 24, seq: 7, source: 'headless' as const } const mocks = vi.hoisted(() => { - const state: Record = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + const state: Record = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } const capturedContexts: ReattachPayloadContext[] = [] const callOrder: string[] = [] return { state, capturedContexts, callOrder } @@ -141,7 +146,12 @@ function decidedContext(): ReattachPayloadContext { beforeEach(() => { vi.clearAllMocks() - mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + mocks.state = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } mocks.capturedContexts = [] mocks.callOrder = [] }) @@ -333,6 +343,7 @@ describe('handleReattachResult empty reattach under a hibernation note', () => { const startup: ColdRestoreAgentResumeStartup = { command: 'codex resume conv-1', agent: 'codex', + resumeOrigin: 'saved', resumeProviderSession: record.providerSession, launchConfig: { agentArgs: '', agentEnv: {} }, launchToken: 'token-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index 77be616b605..f5e917963bc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -1,3 +1,8 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext, + type TerminalLaunchContext +} from './terminal-launch-result-policy' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport-types' @@ -45,6 +50,7 @@ type ReattachResultSession = ReattachPayloadSession & | 'handleReattachResult' | 'mountFollowsTerminalPark' | 'registerEffectiveLaunchConfig' + | 'cancelSavedResumeStartup' | 'registerPaneSerializerFor' | 'registerSideEffectFactConsumerForPty' | 'reportPanePtyVisibility' @@ -69,7 +75,11 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi result: PtyConnectResult | string | void, staleSessionId?: string | null, coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, - attemptGeneration = session.transportStreamGeneration + attemptGeneration = session.transportStreamGeneration, + launchContext: TerminalLaunchContext = captureTerminalLaunchContext( + sessionBag, + coldRestoreStartup ?? sessionBag.paneStartup + ) ): Promise => { if (session.disposed) { return false @@ -96,11 +106,21 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.remotePtyIncarnationId = null } + if (connectResult) { + applyTerminalLaunchResult(sessionBag, connectResult, launchContext, 'creator') + } if (connectResult?.exitedBeforeAttach) { // Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach. return true } + // Why: main started a plain shell instead, so nothing of the plan may be registered over it. + const startup = + connectResult?.agentResumeRefused || + connectResult?.agentResumeUnavailable || + connectResult?.isReattach + ? null + : coldRestoreStartup const ptyId = connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId()) const hasExplicitPtyId = Boolean(connectResult?.id || typeof result === 'string') @@ -126,14 +146,6 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi }) return false } - session.registerEffectiveLaunchConfig(connectResult?.launchConfig, { - ...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}), - ...(connectResult?.launchAgent - ? { launchAgent: connectResult.launchAgent } - : coldRestoreStartup - ? { launchAgent: coldRestoreStartup.agent } - : {}) - }) if (connectResult?.sessionExpired) { if (staleSessionId) { session.clearExitedPanePtyLayoutBinding(staleSessionId) @@ -144,7 +156,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) } // Why: a host can invalidate the PTY while the tab stays mounted; replace it in place, not with a stale overlay. - session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + session.startFreshColdRestoreAgentResume(startup, { forceBlankRestoredViewport: true }) return false @@ -172,8 +184,11 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi !(mainStarts && session.mainStartAttach) && hasEmptyReattachRetireEvidence( ptyId, - // Main's attach carries no resume; the evidence reads the pane's record all the same. - mainStarts ? session.buildColdRestoreAgentResumeStartup() : coldRestoreStartup + connectResult.agentResumeRefused + ? null + : mainStarts + ? session.buildColdRestoreAgentResumeStartup() + : coldRestoreStartup ) ) { session.transport.disconnect() @@ -279,7 +294,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi prefetchedParkModelSnapshot, revealFollowsTerminalPark, shouldApplyStructuralPayload, - coldRestoreStartup, + coldRestoreStartup: startup, reattachPayloadApplied: !shouldApplyStructuralPayload, skippedAltFrameCaptureCols: null } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts index c6f2350f748..1f1958a7e27 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts @@ -4,7 +4,12 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' /** Success must settle after no-ID/expiry rejection; otherwise healed attempts stay pending for 31s or failed attaches falsely reopen recovery budgets. */ const mocks = vi.hoisted(() => ({ - state: { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + state: { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } })) vi.mock('@/store', () => ({ @@ -64,7 +69,12 @@ function setPaneTransports(session: ConnectPanePtySession, transports: Map { vi.clearAllMocks() - mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + mocks.state = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } }) describe('handleReattachResult recovery settle', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts new file mode 100644 index 00000000000..6291ea78499 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts @@ -0,0 +1,396 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' +import type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata +} from '@/store/slices/agent-status-contract' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindSettlePaneSerializer } from './pane-serializer-settle' +import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' +import { installSleepingRecordAccess } from './sleeping-record-access' +import { createTestStore, makeTab } from '@/store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '@/store/slices/store-session-test-harness' +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' + +const fixture = vi.hoisted(() => ({ + state: {}, + setState: vi.fn(), + paste: vi.fn(), + parsed: vi.fn(async () => {}) +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => fixture.state, setState: fixture.setState } +})) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + waitForTerminalOutputParsed: fixture.parsed +})) +vi.mock('../terminal-startup-command-paste', () => ({ + executeTerminalStartupCommandPaste: fixture.paste +})) +vi.mock('../terminal-paste-runtime', () => ({ resolveTerminalPasteRuntime: () => ({}) })) +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +vi.mock('./command-inferred-pane-agent', () => ({ installCommandInferredPaneAgent: vi.fn() })) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + fixture.setState.mockReset() + vi.unstubAllGlobals() +}) + +describe('a refused saved startup and its delayed paste', () => { + it.each(['original', 'same-C', 'different-D', 'unverifiable'] as const)( + 'cancels only its captured source and keeps newer work (%s)', + async (kind) => { + vi.useFakeTimers() + const record = { + paneKey: 'tab:leaf', + tabId: 'tab', + worktreeId: 'wt', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + connectionId: null, + state: 'done' as const, + prompt: '', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep' as const + } + const source = { cancelled: false, record } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + resumeProviderSession: record.providerSession, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const records = kind === 'unverifiable' ? {} : { [record.paneKey]: record } + const clear = vi.fn() + const remember = vi.fn() + fixture.state = { + sleepingAgentSessionsByPaneKey: records, + automaticAgentResumeClaimsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + pendingStartupByTabId: {}, + tabsByWorktree: {}, + registerAgentLaunchConfig: vi.fn(), + clearAgentLaunchConfig: vi.fn(), + consumeTabStartupCommand: vi.fn(), + clearSleepingAgentSession: clear, + rememberSleepingAgentSession: remember + } + const sendInput = vi.fn(() => true) + const transport = { getPtyId: () => 'shell', sendInput } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installers' accessed session members are supplied here. + const session = { + pane: { id: 1, leafId: 'leaf', terminal: {} }, + manager: {}, + transport, + deps: { + worktreeId: 'wt', + tabId: 'tab', + paneTransportsRef: { current: new Map([[1, transport]]) } + }, + cacheKey: record.paneKey, + paneStartup: startup, + startupInjectTimer: null, + disposed: false, + transportStreamGeneration: 1, + shouldDeliverStartupViaTerminalPaste: true, + releaseUnattemptedStartupDraftPasteDelivery: vi.fn() + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + bindSettlePaneSerializer(session) + bindDeferredColdRestoreAndSnapshot(session) + let finishPaste: (() => void) | undefined + fixture.paste.mockImplementation( + () => + new Promise((resolve) => { + finishPaste = () => resolve({ status: 'pasted' }) + }) + ) + session.schedulePendingStartupCommandDelivery() + await vi.advanceTimersByTimeAsync(50) + expect(fixture.paste).toHaveBeenCalledOnce() + + const newer = { command: kind === 'different-D' ? 'codex resume D' : 'codex resume C' } + session.pendingStartupCommand = newer + if (kind === 'same-C' || kind === 'different-D') { + Object.assign(records, { + [record.paneKey]: { + ...record, + providerSession: { ...record.providerSession, id: kind === 'same-C' ? 'C' : 'D' }, + updatedAt: 20 + } + }) + } + session.cancelSavedResumeStartup(startup, { + id: 'shell', + agentResumeRefused: kind === 'unverifiable' ? 'unverifiable' : 'chat-home' + }) + expect(source.cancelled).toBe(true) + expect(session.pendingStartupCommand).toBe(newer) + finishPaste?.() + await vi.advanceTimersByTimeAsync(0) + expect(sendInput).not.toHaveBeenCalled() + expect(session.pendingStartupCommand).toBe(newer) + if (kind === 'unverifiable') { + expect(remember).toHaveBeenCalledWith(record) + } else if (kind === 'original') { + expect(clear).toHaveBeenCalledWith(record.paneKey) + } else { + expect(clear).not.toHaveBeenCalled() + } + } + ) +}) + +it.each(['refused', 'same-C', 'different-D'] as const)( + 'clears the real generated cold/wake registration and conditional tab identity only while owned (%s)', + (kind) => { + const cacheKey = 'tab:leaf' + const source = { cancelled: false } + const startup: ColdRestoreAgentResumeStartup = { + agent: 'codex', + command: 'codex resume C', + launchToken: 'saved-token', + launchConfig: { agentCommand: 'codex', agentArgs: 'resume C', agentEnv: {} }, + resumeProviderSession: { key: 'session_id', id: 'C' }, + resumeOrigin: 'saved', + resumeSource: source, + useLiveEntry: false, + hasSleepingRecord: false, + sleepingRecordEntry: null + } + const registry: Record = {} + const pending: Record = {} + const tab = { id: 'tab', title: 'Codex', launchAgent: 'codex', generation: 1 } + const clearTabLaunchAgent = vi.fn() + const registerAgentLaunchConfig = ( + paneKey: string, + launchConfig: AgentLaunchConfigRegistryEntry['launchConfig'], + identity: AgentLaunchConfigRegistrationMetadata + ) => { + registry[paneKey] = { launchConfig, identity, registeredAt: 1 } + } + fixture.state = { + agentLaunchConfigByPaneKey: registry, + pendingStartupByTabId: pending, + automaticAgentResumeClaimsByTabId: {}, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { wt: [tab] }, + registerAgentLaunchConfig, + clearAgentLaunchConfig: (key: string) => { + delete registry[key] + }, + consumeTabStartupCommand: vi.fn(), + clearTabLaunchAgent + } + const updateTabTitle = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real installers/cancellation access only the supplied session members in this contract. + const session = { + cacheKey, + paneStartup: null, + pane: { id: 1, leafId: 'leaf' }, + manager: { getActivePane: () => ({ id: 1 }) }, + deps: { tabId: 'tab', worktreeId: 'wt', setRuntimePaneTitle: vi.fn(), updateTabTitle }, + tabGeneration: 1, + startupInjectTimer: null, + transport: {}, + shouldDeliverStartupViaTerminalPaste: false + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + bindDeferredColdRestoreAndSnapshot(session) + expect(session.applyColdRestoreAgentResumeStartup(startup)).toBe(true) + const saved = registry[cacheKey] + expect(saved?.identity.launchToken).toBe('saved-token') + if (kind !== 'refused') { + const command = kind === 'same-C' ? 'resume C' : 'resume D' + registerAgentLaunchConfig( + cacheKey, + { ...startup.launchConfig, agentArgs: command }, + { + agentType: 'codex', + launchToken: 'explicit-token' + } + ) + pending.tab = { command } + } + const current = registry[cacheKey] + const newer = pending.tab + session.cancelSavedResumeStartup(startup, { id: 'shell', agentResumeRefused: 'chat-home' }) + session.registerEffectiveLaunchConfig(undefined, {}) + expect(source.cancelled).toBe(true) + expect(session.paneStartup).toBeNull() + if (kind === 'refused') { + expect(registry[cacheKey]).toBeUndefined() + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab') + expect(updateTabTitle).toHaveBeenCalledWith('tab', 'Terminal') + } else { + expect(registry[cacheKey]).toBe(current) + expect(pending.tab).toBe(newer) + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + expect(updateTabTitle).not.toHaveBeenCalled() + } + } +) + +it.each([10, 100])('counts real saved-startup cancellation map work at %i entries', (size) => { + vi.stubGlobal('window', undefined) + createStoreSessionMockApi() + for (const newer of [true, false]) { + const store = createTestStore() + fixture.state = store.getState() + fixture.setState.mockImplementation(store.setState) + let notifications = 0 + const unsubscribe = store.subscribe((state) => { + fixture.state = state + notifications += 1 + }) + const source = { cancelled: false } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + launchToken: 'saved-token', + launchConfig: { agentCommand: 'codex', agentArgs: 'resume C', agentEnv: {} }, + resumeProviderSession: { key: 'session_id' as const, id: 'C' }, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const claim = { + worktreeId: 'wt', + launchAgent: 'codex' as const, + providerSession: startup.resumeProviderSession + } + const status: AgentStatusEntry = { + paneKey: 'tab:leaf', + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [] + } + store.setState({ automaticAgentResumeClaimsByTabId: { tab: claim } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the actual installers/result policy access only these supplied session members. + const session = { + cacheKey: 'tab:leaf', + pane: { id: 1, leafId: 'leaf' }, + paneStartup: startup, + pendingStartupCommand: startup, + startupInjectTimer: null, + deps: { tabId: 'tab', worktreeId: 'wt' }, + showSessionRestoredBanner: vi.fn() + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + const registration = store.getState().agentLaunchConfigByPaneKey['tab:leaf']! + session.savedStartupStatus = { source, entry: status } + let enumerated = 0 + let copied = 0 + const counted = (map: T): T => + new Proxy(map, { + ownKeys(target) { + const keys = Reflect.ownKeys(target) + enumerated += keys.length + return keys + }, + getOwnPropertyDescriptor(target, key) { + const descriptor = Object.getOwnPropertyDescriptor(target, key) + if (descriptor?.enumerable) { + copied += 1 + } + return descriptor + } + }) + const population = (target: string, value: T) => + Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + index === size - 1 ? target : `other-${index}`, + value + ]) + ) + let tabVisits = 0 + const tabs = Array.from({ length: size }, (_, index) => + makeTab({ + id: index === size - 1 ? 'tab' : `other-${index}`, + worktreeId: 'wt' + }) + ) + // Keep the real tab objects; count the actual find predicate's identity reads. + tabs.forEach((tab, index) => + Object.defineProperty(tab, 'id', { + get: () => { + tabVisits += 1 + return index === size - 1 ? 'tab' : `other-${index}` + } + }) + ) + store.setState({ + pendingStartupByTabId: counted(population('tab', startup)), + automaticAgentResumeClaimsByTabId: counted(population('tab', claim)), + agentLaunchConfigByPaneKey: counted(population('tab:leaf', registration)), + agentStatusByPaneKey: counted(population('tab:leaf', status)), + tabsByWorktree: { wt: tabs } + }) + const context = captureTerminalLaunchContext(session, startup) + const successor = { ...startup, resumeSource: { cancelled: false } } + const successorRegistration = { ...registration, registeredAt: 2 } + if (newer) { + session.paneStartup = successor + session.pendingStartupCommand = successor + store.setState({ + pendingStartupByTabId: counted(population('tab', successor)), + automaticAgentResumeClaimsByTabId: counted(population('tab', { ...claim })), + agentLaunchConfigByPaneKey: counted(population('tab:leaf', successorRegistration)), + agentStatusByPaneKey: counted(population('tab:leaf', { ...status, updatedAt: 2 })) + }) + } + enumerated = 0 + copied = 0 + notifications = 0 + tabVisits = 0 + const result = { + agentResumeRefused: 'chat-home' as const, + savedResumeDecision: { + agent: 'codex', + providerSession: startup.resumeProviderSession, + verdict: 'chat-home' as const + } + } + try { + expect(applyTerminalLaunchResult(session, result, context, 'creator')).toBe(true) + expect(source.cancelled).toBe(true) + // Four independent whole-map spreads, once each; this is work per result, not a latency bound. + expect([enumerated, copied, notifications, tabVisits]).toEqual( + newer ? [0, 0, 0, 0] : [4 * size, 4 * size, 4, size] + ) + const current = store.getState() + if (newer) { + expect(current.pendingStartupByTabId.tab).toBe(successor) + expect(current.agentLaunchConfigByPaneKey['tab:leaf']).toBe(successorRegistration) + expect(current.automaticAgentResumeClaimsByTabId.tab).not.toBe(claim) + expect(current.agentStatusByPaneKey['tab:leaf']?.updatedAt).toBe(2) + expect(session.paneStartup).toBe(successor) + expect(successor.resumeSource.cancelled).toBe(false) + } else { + expect(current.pendingStartupByTabId.tab).toBeUndefined() + expect(current.agentLaunchConfigByPaneKey['tab:leaf']).toBeUndefined() + expect(current.automaticAgentResumeClaimsByTabId.tab).toBeUndefined() + expect(current.agentStatusByPaneKey['tab:leaf']).toBeUndefined() + expect(current.pendingStartupByTabId['other-0']).toBe(startup) + expect(session.pendingStartupCommand).toBeNull() + } + enumerated = 0 + copied = 0 + notifications = 0 + tabVisits = 0 + applyTerminalLaunchResult(session, result, context, 'creator') + expect([enumerated, copied, notifications, tabVisits]).toEqual([0, 0, 0, 0]) + } finally { + unsubscribe() + } + } +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts new file mode 100644 index 00000000000..f995fadd0f2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts @@ -0,0 +1,108 @@ +import { forgetRefusedPaneResume } from '@/lib/forget-refused-pane-resume' +import { useAppStore } from '@/store' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { PtyPaneStartup } from '../pty-connection-types' +import type { PtyConnectResult } from '../pty-transport-types' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { clearStaleAgentTabTitleOnConfirmedShell } from './confirmed-shell-agent-title' + +export function installSavedResumeStartupCancellation(session: ConnectPanePtySession): void { + session.cancelSavedResumeStartup = ( + startup: ColdRestoreAgentResumeStartup | PtyPaneStartup | null | undefined, + result: PtyConnectResult + ): void => { + const source = startup?.resumeSource + if (!startup || !source) { + return + } + source.cancelled = true + const state = useAppStore.getState() + if (session.pendingStartupCommand?.resumeSource === source) { + session.pendingStartupCommand = null + if (session.startupInjectTimer !== null) { + clearTimeout(session.startupInjectTimer) + } + session.startupInjectTimer = null + } + if (session.deferredSpawn?.startupOverride?.resumeSource === source) { + session.deferredSpawn = null + } + const ownsPaneStartup = session.paneStartup?.resumeSource === source + if (ownsPaneStartup) { + session.paneStartup = null + session.startupDraftPrompt = null + } + state.consumeTabStartupCommand(session.deps.tabId, startup) + const registered = session.savedStartupLaunchRegistration + const ownsRegistration = + registered?.source === source && + registered.entry !== undefined && + registered.entry === state.agentLaunchConfigByPaneKey[session.cacheKey] + session.clearRegisteredStartupLaunchConfig(startup) + const claim = state.automaticAgentResumeClaimsByTabId[session.deps.tabId] + if (ownsPaneStartup && claim && claim === session.savedStartupAutomaticClaim) { + useAppStore.setState((current) => { + if (current.automaticAgentResumeClaimsByTabId[session.deps.tabId] !== claim) { + return current + } + const next = { ...current.automaticAgentResumeClaimsByTabId } + delete next[session.deps.tabId] + return { automaticAgentResumeClaimsByTabId: next } + }) + } + if (ownsPaneStartup || ownsRegistration) { + const pending = state.pendingStartupByTabId[session.deps.tabId] + const installed = useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + const replaced = pending && pending.resumeSource !== source + if (!replaced && !installed) { + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + ) + if ( + tab?.launchAgent === ('agent' in startup ? startup.agent : startup.launchAgent) && + (tab?.generation ?? 0) === session.tabGeneration + ) { + state.clearTabLaunchAgent(session.deps.tabId) + clearStaleAgentTabTitleOnConfirmedShell(session) + } + } + } + if (ownsPaneStartup) { + const seeded = session.savedStartupStatus + if (seeded?.source === source) { + useAppStore.setState((current) => { + if (current.agentStatusByPaneKey[session.cacheKey] !== seeded.entry) { + return current + } + const next = { ...current.agentStatusByPaneKey } + delete next[session.cacheKey] + return { agentStatusByPaneKey: next } + }) + } + session.cleanupStartupDraftPasteTimers?.() + } + if ( + result.agentResumeRefused !== 'unverifiable' && + source.expectedStatus && + startup.resumeProviderSession + ) { + forgetRefusedPaneResume(state, source.expectedStatus.paneKey, { + agent: 'agent' in startup ? startup.agent : (startup.launchAgent ?? ''), + providerSession: startup.resumeProviderSession, + expectedStatus: source.expectedStatus + }) + } + const captured = source.record + if (!captured) { + return + } + const current = state.sleepingAgentSessionsByPaneKey[captured.paneKey] + if (result.agentResumeRefused === 'unverifiable') { + if (!current) { + state.rememberSleepingAgentSession(captured) + } + } else if (current === captured) { + state.clearSleepingAgentSession(captured.paneKey) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index af819c8bda1..d94232a2901 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -1,3 +1,5 @@ +import { installSavedResumeStartupCancellation } from './saved-resume-startup-cancellation' +import { launchConfigsEqual } from '../../../../../shared/agent-resume-record-equivalence' import { useAppStore } from '@/store' import type { PtyConnectResult } from '../pty-transport-types' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -55,6 +57,9 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi } } } + session.savedStartupAutomaticClaim = session.paneStartup?.resumeSource + ? useAppStore.getState().automaticAgentResumeClaimsByTabId[session.deps.tabId] + : undefined session.launchToken = session.paneStartup?.launchConfig ? (session.paneStartup.launchToken ?? createBrowserUuid()) : undefined @@ -114,6 +119,12 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi tabId: session.deps.tabId, leafId: session.pane.leafId }) + if (session.paneStartup.resumeSource) { + session.savedStartupLaunchRegistration = { + source: session.paneStartup.resumeSource, + entry: useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + } + } } else if (session.paneStartup) { useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) } @@ -154,9 +165,24 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi leafId: session.pane.leafId }) } - session.clearRegisteredStartupLaunchConfig = (): void => { - useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) + session.clearRegisteredStartupLaunchConfig = (expected = session.paneStartup): void => { + const state = useAppStore.getState() + const installed = state.agentLaunchConfigByPaneKey[session.cacheKey] + const captured = session.savedStartupLaunchRegistration + const ownsRegistration = expected?.resumeSource + ? captured?.source === expected.resumeSource && captured.entry === installed + : true + if ( + ownsRegistration && + installed && + expected?.launchConfig && + installed.identity.launchToken === (expected.launchToken ?? session.launchToken) && + launchConfigsEqual(installed.launchConfig, expected.launchConfig) + ) { + state.clearAgentLaunchConfig(session.cacheKey) + } } + installSavedResumeStartupCancellation(session) session.neutralTerminalTitle = (): string => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts index df90afb9106..0f3232297d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts @@ -149,17 +149,26 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void session.pendingSpawnDeferral = null session.deferredSpawn = null + /** Keeps the resume handle of a restore the host did not run, so a later restore tries again. */ + session.keepUnrunResumeHandle = ( + startupOverride: PendingStartupCommand | null | undefined + ): void => { + const captured = + startupOverride?.resumeSource?.record ?? session.paneStartup?.resumeSource?.record + const record = captured ?? deferredResumeRecord(session, startupOverride) + if (record && !useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]) { + // Why left after a later resume: the next quit capture replaces it, and a pane with a live PTY owns it. + useAppStore.getState().rememberSleepingAgentSession(record) + } + } + session.enterSpawnDeferredSleep = ( reason: SpawnDeferralReason, startupOverride: PendingStartupCommand | null | undefined, options: FreshSpawnOptions, visibleAtSpawn: boolean ): void => { - const record = deferredResumeRecord(session, startupOverride) - if (record) { - // Why left after a later resume: the next quit capture replaces it, and a pane with a live PTY owns it. - useAppStore.getState().rememberSleepingAgentSession(record) - } + session.keepUnrunResumeHandle(startupOverride) session.deferredSpawn = { reason, startupOverride, options } const deferral = { reason, resume: () => void session.resumeDeferredSpawn() } setPaneSpawnDeferral(session.cacheKey, deferral) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts new file mode 100644 index 00000000000..3a62b60f9ac --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts @@ -0,0 +1,95 @@ +import { + agentProviderSessionsEqual, + type TerminalLaunchResult +} from '../../../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { ReattachPayloadSession } from './reattach-payload-session' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { PtyPaneStartup } from '../pty-connection-types' + +type SavedStartup = ColdRestoreAgentResumeStartup | PtyPaneStartup + +export function captureTerminalLaunchContext( + session: ConnectPanePtySession | ReattachPayloadSession, + startup: SavedStartup +) { + const state = useAppStore.getState() + return { + startup, + registration: state.agentLaunchConfigByPaneKey[session.cacheKey], + status: state.agentStatusByPaneKey[session.cacheKey], + paneStartup: session.paneStartup + } +} + +export type TerminalLaunchContext = ReturnType + +export function applyTerminalLaunchResult( + session: ConnectPanePtySession | ReattachPayloadSession, + result: TerminalLaunchResult & { isReattach?: boolean }, + context: TerminalLaunchContext, + kind: 'creator' | 'observer' | 'candidate' +): boolean { + const startup = context.startup + const decision = result.savedResumeDecision + const agent = startup && ('agent' in startup ? startup.agent : startup.launchAgent) + const matches = Boolean( + startup && + decision && + decision.agent === agent && + agentProviderSessionsEqual( + decision.agent, + decision.providerSession, + startup.resumeProviderSession + ) + ) + const candidateRejected = kind === 'candidate' && (!matches || decision?.verdict !== 'admitted') + const rejected = Boolean( + result.agentResumeRefused || result.agentResumeUnavailable || candidateRejected + ) + const state = useAppStore.getState() + const currentStartup = session.paneStartup + const currentRegistration = state.agentLaunchConfigByPaneKey[session.cacheKey] + const superseded = + (kind === 'observer' && startup && decision && !matches) || + (currentStartup && + currentStartup !== context.paneStartup && + currentStartup.resumeSource !== startup?.resumeSource) || + (currentRegistration && currentRegistration !== context.registration) || + (state.agentStatusByPaneKey[session.cacheKey] && + state.agentStatusByPaneKey[session.cacheKey] !== context.status) + try { + if ( + rejected && + (!result.isReattach || result.agentResumeRefused) && + (matches || kind === 'candidate' || (kind === 'creator' && !result.isReattach)) + ) { + session.cancelSavedResumeStartup( + startup, + candidateRejected ? { ...result, agentResumeRefused: 'unverifiable' } : result + ) + } + } catch (error) { + console.warn('Terminal saved startup cancellation failed', error) + } + try { + if (!superseded && kind !== 'candidate') { + session.registerEffectiveLaunchConfig(result.launchConfig, { + launchToken: result.launchToken, + launchAgent: + result.launchAgent ?? + (!rejected && !result.isReattach && startup && 'agent' in startup + ? startup.agent + : undefined) + }) + } + } catch (error) { + // A bound shell remains usable even when exact-source bookkeeping fails. + console.warn('Terminal launch result bookkeeping failed', error) + } + if (rejected && !superseded) { + session.showSessionRestoredBanner('resume-unavailable') + } + return rejected +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts index 750d79cd8d3..263fd5e1ef7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts @@ -65,9 +65,10 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { } session.applyInitialAgentStatus = (terminalTitle?: string): void => { - const initialStatus = session.paneStartup?.initialAgentStatus + const startup = session.paneStartup + const initialStatus = startup?.initialAgentStatus const routing = session.resolveCurrentAgentStatusRouting() - if (!initialStatus || !routing) { + if (!initialStatus || !routing || startup.resumeSource?.cancelled) { return } const statusPayload = { @@ -90,11 +91,17 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { launchConfig: session.paneStartup.launchConfig, ...(session.launchToken ? { launchToken: session.launchToken } : {}) }) - return + } else { + useAppStore + .getState() + .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing) + } + if (startup.resumeSource) { + session.savedStartupStatus = { + source: startup.resumeSource, + entry: useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + } } - useAppStore - .getState() - .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing) } session.canApplyCommandCodeOutputStatus = (): boolean => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts index 5ad7e57f996..eb45ac1c7ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts @@ -1,3 +1,9 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { TerminalAttachmentLaunchResult } from '../../../../../shared/terminal-attachment-launch-result' import { isPaneOwnerRefusal } from './pane-owner-recovery' import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission' import { createTerminalStartupTiming } from '../terminal-startup-timing' @@ -12,8 +18,10 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi const ownerErrors = new Set() session.captureTransportOutputCallbacks = ( onError: (message: string) => void, - startup: PtyPaneStartup + startup: PtyPaneStartup, + savedStartup: ColdRestoreAgentResumeStartup | PtyPaneStartup = session.paneStartup ) => { + const launchContext = captureTerminalLaunchContext(session, savedStartup) // New stream generations drop old replay fit continuations and release live-data waiters. session.pendingHiddenSnapshotFit?.cancel() session.pendingHiddenSnapshotFit = null @@ -38,7 +46,18 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi }) return { generation, + launchContext, callbacks: { + onLaunchResult: (result: TerminalAttachmentLaunchResult): void => { + if ( + isCurrent() && + (!session.remotePtyIncarnationId || + session.remotePtyIncarnationId === result.incarnationId) + ) { + session.remotePtyIncarnationId = result.incarnationId + applyTerminalLaunchResult(session, result, launchContext, 'observer') + } + }, onReattachDetermined: (): void => { if (isCurrent()) { session.beginReattachLiveDataDeferralIfUnowned(generation) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 529502d055f..3142f589247 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,3 +1,4 @@ +import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { RestoreSpawnIntent, @@ -5,6 +6,7 @@ import type { } from '../../../../shared/host-spawn-admission' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -80,7 +82,7 @@ export type LocalPtySessionMetadata = { shellOverride?: string } -export type PtyConnectResult = { +export type PtyConnectResult = TerminalLaunchResult & { id: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string @@ -112,11 +114,15 @@ export type PtyConnectResult = { startupCwdFallback?: { kind: 'worktree'; cwd: string } /** Main declined an unverifiable provider-session resume and launched fresh. */ agentResumeUnavailable?: true + /** Main refused the resume and started the plain shell: a native chat is the conversation's home + * or may be writing it, or no record could be checked (the saved session is kept to retry). */ + agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' /** Write daemon’s trailing partial escape after reset so racing live bytes can complete it (#7329). */ pendingEscapeTailAnsi?: string } type PtyCallbacks = { + onLaunchResult?: (result: TerminalAttachmentLaunchResult) => void /** Called before an adopted PTY can publish buffered/live bytes. */ onReattachDetermined?: () => void onConnect?: () => void @@ -178,6 +184,7 @@ export type PtyTransport = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -274,6 +281,7 @@ export type IpcPtyTransportOptions = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' agentPrompt?: string agentPromptDelivery?: AgentPromptDelivery agentArgsOverride?: string | null diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts index b512254b5ee..1cdd5cfc05c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts @@ -7,17 +7,23 @@ import { let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' -const { runtimeCall, refreshSessionTabsSnapshot, resetRemoteRuntimeTransport } = - createRemoteRuntimeTransportMocks({ - getCallbacks: () => subscriptionCallbacks, - setCallbacks: (callbacks) => { - subscriptionCallbacks = callbacks - }, - getResolvedPaneHandle: () => resolvedPaneHandle, - setResolvedPaneHandle: (handle) => { - resolvedPaneHandle = handle - } - }) +const { + runtimeCall, + refreshSessionTabsSnapshot, + resetRemoteRuntimeTransport, + latestSubscribePayload, + emitSnapshot, + inputFrameTexts +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) describe('createRemoteRuntimePtyTransport', () => { it.each([ @@ -498,4 +504,55 @@ describe('createRemoteRuntimePtyTransport', () => { expect.objectContaining({ method: 'terminal.createAgentSession' }) ) }) + it.each(['existing', 'fresh', 'legacy'] as const)( + 'uses subscription image for an existing intermediate-host owner and keeps fresh cold data (%s)', + async (kind) => { + const coldRestore = { scrollback: 'old checkpoint A', cwd: '/workspace' } + runtimeCall.mockImplementation(async (args: { method: string }) => ({ + ok: true, + result: + args.method === 'terminal.create' + ? { + terminal: { + handle: 'terminal-1', + ...(kind === 'existing' ? { isReattach: true, incarnationId: 'i1' } : {}), + ...(kind !== 'legacy' ? { coldRestore } : {}) + } + } + : {} + })) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const onReplayData = vi.fn() + const onConnect = vi.fn() + const result = await transport.connect({ url: '', callbacks: { onReplayData, onConnect } }) + try { + if (!result || typeof result === 'string') { + throw new Error('Expected a terminal connection result') + } + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + expect(result?.coldRestore).toEqual(kind === 'fresh' ? coldRestore : undefined) + expect(result?.isReattach).toBe(kind === 'existing' ? true : undefined) + expect(result?.replay).toBe('') + const { streamId } = latestSubscribePayload() + emitSnapshot(streamId, 'actual owner image B') + expect(onReplayData.mock.calls).toEqual([ + ['actual owner image B', { carriesNormalBuffer: true }] + ]) + expect(onConnect).toHaveBeenCalled() + expect(inputFrameTexts()).toEqual([]) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + } finally { + transport.destroy?.() + } + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 0e2213064b4..043a0b945fa 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -1,3 +1,4 @@ +import { ExplicitEnsure } from '../../../../shared/rpc-contract/agent-session-params' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appStore = vi.hoisted(() => ({ getState: vi.fn(() => { @@ -126,6 +127,63 @@ describe('createRemoteRuntimePtyTransport', () => { return payload } + it('round-trips a strict saved ensure and its actual refused shell result', async () => { + const providerSession = { key: 'session_id' as const, id: 'C' } + const decision = { agent: 'codex' as const, providerSession, verdict: 'chat-home' as const } + runtimeCall.mockImplementation(async (args: { method?: string; params?: unknown }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent-session.host-authority.v1', + 'agent-session.saved-resume-origin.v1' + ] + } + } + } + expect(args.method).toBe('terminal.ensureAgentSession') + expect(ExplicitEnsure.parse(args.params)).toMatchObject({ + resumeOrigin: 'saved', + providerSession + }) + return { + ok: true, + result: { + disposition: 'created', + terminal: { + handle: 'term-remote', + worktreeId: 'repo1::/remote/wt', + title: null, + savedResumeDecision: decision, + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true + } + } + } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + launchAgent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: providerSession, + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' + }) + const result = await transport.connect({ url: '', callbacks: {} }) + expect(result).toMatchObject({ + id: 'remote:env-1@@term-remote', + savedResumeDecision: decision, + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true + }) + expect(result).not.toHaveProperty('launchAgent') + transport.disconnect() + }) + it('creates and subscribes to a terminal on the active remote runtime', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onReplayData = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts index 9ab9a4220f6..dd77e1afd8d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts @@ -21,7 +21,7 @@ function okResponse(result: unknown): unknown { } describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { - const runtimeCall = vi.fn() + const runtimeCall = vi.fn<(args: RpcCall) => Promise>() let capabilities: string[] = [] let ensureResponse: unknown = null @@ -65,12 +65,16 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { vi.unstubAllGlobals() }) - async function connectResume(callbacks: Record = {}): Promise { + async function connectResume( + callbacks: Record = {}, + saved = false + ): Promise { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'repo1::/remote/wt', launchAgent: 'claude', resumeProviderSession: RESUME, + ...(saved ? { resumeOrigin: 'saved' as const } : {}), tabId: 'tab-1', leafId: LEAF_ID }) @@ -103,6 +107,46 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { expect(ensureParams()).not.toHaveProperty('spawnIntent') }) + it.each([false, true])( + 'keeps saved-origin negotiation stable through an unknown outcome (capability %s)', + async (modern) => { + if (modern) { + capabilities.push('agent-session.saved-resume-origin.v1') + } + let attempts = 0 + runtimeCall.mockImplementation(async (args: RpcCall) => { + if (args.method === 'status.get') { + return okResponse({ + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities + }) + } + if (args.method === 'terminal.ensureAgentSession' && attempts++ === 0) { + capabilities = [...capabilities, 'agent-session.saved-resume-origin.v1'] + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + return ensureResponse + }) + const onSpawnDeferred = vi.fn() + await connectResume({ onSpawnDeferred }, true) + const requests = runtimeCall.mock.calls + .map(([call]) => call) + .filter((call) => call.method === 'terminal.ensureAgentSession') + expect(requests).toHaveLength(2) + expect(requests[1].params).toEqual(requests[0].params) + expect(requests[0].params).toMatchObject({ kind: 'explicit', providerSession: RESUME }) + if (modern) { + expect(requests[0].params).toHaveProperty('resumeOrigin', 'saved') + } else { + expect(requests[0].params).not.toHaveProperty('resumeOrigin') + } + expect(onSpawnDeferred).toHaveBeenCalledWith('paused') + } + ) + it('reports a deferral to the pane instead of surfacing an error', async () => { capabilities.push('terminal.spawn-intent.v1') const onError = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 0a1933a946e..b2133318648 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,5 +1,11 @@ +import { parseTerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' +import { + terminalLaunchFacts, + agentProviderSessionForEnsure +} from '../../../../shared/agent-session-resume' +import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' -import { agentProviderSessionForEnsure } from '../../../../shared/agent-session-resume' import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent' import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' import { createRemoteRuntimeDisconnectedInputGrace } from './remote-runtime-disconnected-input-grace' @@ -36,7 +42,10 @@ import type { RuntimeTerminalSend, RuntimeTerminalShow } from '../../../../shared/runtime-types' -import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { agentResumeHostAuthorityCapability } from '../../runtime/agent-resume-host-authority-capability' import { isTerminalInputTooLargeWithDeferredMeasurement, @@ -212,6 +221,7 @@ export function createRemoteRuntimePtyTransport( envToDelete, launchConfig, resumeProviderSession, + resumeOrigin, launchToken, launchAgent, terminalColorQueryReplies, @@ -568,6 +578,8 @@ export function createRemoteRuntimePtyTransport( } // Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records. const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}` + let replayLaunch: ((epoch: number) => Promise) | undefined + let savedOriginOptions: Promise<{ resumeOrigin?: 'saved' }> | undefined const terminalCreateMutationId = createBrowserUuid() // Replay one stable host operation after lost responses so reconnect cannot launch a second agent. const agentCreateOperation = createAgentSessionCreateOperation() @@ -1652,6 +1664,11 @@ export function createRemoteRuntimePtyTransport( if (!expiredHandle || !tabId || !leafId || !worktreeId || recoveringPaneHandle) { return } + const recoverySubscriptionGeneration = subscriptionGeneration + const recoveryLifecycleEpoch = lifecycleEpoch + const recoveryCallbacks = storedCallbacks + const ownsRecoveryLifecycle = (): boolean => + recoveryLifecycleEpoch === lifecycleEpoch && recoveryCallbacks === storedCallbacks recoveringPaneHandle = expiredHandle connected = false clearPendingViewportClaim() @@ -1663,7 +1680,7 @@ export function createRemoteRuntimePtyTransport( expectedTerminal: expiredHandle }) .then(async ({ terminal }) => { - if (destroyed || handle !== expiredHandle) { + if (destroyed || handle !== expiredHandle || !ownsRecoveryLifecycle()) { return } const previousIncarnationId = authoritativePtyIncarnationId @@ -1688,10 +1705,32 @@ export function createRemoteRuntimePtyTransport( onPtyRebind?.(remotePtyId, replacedPtyId) } } - await subscribeToHandle() + if (!ownsRecoveryLifecycle()) { + return + } + const launchResult = parseTerminalAttachmentLaunchResult({ + ...terminalLaunchFacts(terminal), + ptyId: terminal.ptyId, + incarnationId: terminal.incarnationId + }) + if ( + launchResult && + ownsRecoveryLifecycle() && + recoverySubscriptionGeneration === subscriptionGeneration && + currentPaneRevision() === getRuntimeEnvironmentRevision(currentRuntimeEnvironmentId) + ) { + try { + recoveryCallbacks.onLaunchResult?.(launchResult) + } catch (error) { + console.warn('Terminal launch result bookkeeping failed', error) + } + } + if (ownsRecoveryLifecycle()) { + await subscribeToHandle() + } }) .catch((error) => { - if (!destroyed && handle === expiredHandle) { + if (!destroyed && handle === expiredHandle && ownsRecoveryLifecycle()) { surfaceErrorMessage(runtimeTerminalErrorMessage(error)) } }) @@ -2837,6 +2876,10 @@ export function createRemoteRuntimePtyTransport( } const subscribedHandle = handle const subscribedPtyId = remotePtyId + const subscribedLifecycleEpoch = lifecycleEpoch + const subscribedCallbacks = storedCallbacks + const ownsSubscriptionLifecycle = (): boolean => + subscribedLifecycleEpoch === lifecycleEpoch && subscribedCallbacks === storedCallbacks const generation = ++subscriptionGeneration const recoveryEpoch = expectedRecoveryEpoch ?? (recovery.isActive ? recovery.currentEpoch : undefined) @@ -2851,6 +2894,7 @@ export function createRemoteRuntimePtyTransport( const isCurrentSubscription = (): boolean => !transportClosed && generation === subscriptionGeneration && + ownsSubscriptionLifecycle() && (recoveryEpoch === undefined || recovery.isCurrent(recoveryEpoch) || (subscriptionAttached && recovery.ownsEpoch(recoveryEpoch))) && @@ -2925,8 +2969,23 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onPausedOutputDropped?.() } }, - onSubscribeAcknowledged: () => { + onSubscribeAcknowledged: (result) => { if (isCurrentSubscription()) { + if ( + result && + (!authoritativePtyIncarnationId || + result.incarnationId === authoritativePtyIncarnationId) + ) { + authoritativePtyIncarnationId = result.incarnationId + try { + subscribedCallbacks.onLaunchResult?.(result) + } catch (error) { + console.warn('Terminal launch result bookkeeping failed', error) + } + } + if (!isCurrentSubscription()) { + return + } inputReadySubscription = generation if ( installedStream && @@ -3093,6 +3152,7 @@ export function createRemoteRuntimePtyTransport( }) if ( transportClosed || + !ownsSubscriptionLifecycle() || generation !== subscriptionGeneration || (recoveryEpoch !== undefined && !(subscriptionAttached @@ -3397,6 +3457,23 @@ export function createRemoteRuntimePtyTransport( if (restoreSpawnIntent && (destroyed || lifecycleEpoch !== connectLifecycleEpoch)) { return } + const originOptions = + (options.resumeOrigin ?? resumeOrigin) === 'saved' + ? await (savedOriginOptions ??= (async () => { + try { + const supported = await runtimeEnvironmentSupportsCapability( + createEnvironmentId, + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY + ) + return supported ? { resumeOrigin: 'saved' as const } : {} + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + return {} + } + })()) + : {} const legacyCreateParams = { worktree: toRuntimeTerminalWorktreeSelector(worktreeId), clientMutationId: terminalCreateMutationId, @@ -3410,6 +3487,7 @@ export function createRemoteRuntimePtyTransport( ...(resumeProviderSessionToSend !== undefined ? { resumeProviderSession: resumeProviderSessionToSend } : {}), + ...originOptions, ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), ...spawnIntentParams, @@ -3422,7 +3500,7 @@ export function createRemoteRuntimePtyTransport( presentation: 'background' as const, ...(activate === true ? { activate: true } : {}) } - const legacyCreate = () => + const legacyCreate = (epoch = connectLifecycleEpoch) => createWithUnknownOutcomeRecovery( 'terminal', (timeoutMs, reconcileExisting) => @@ -3436,9 +3514,9 @@ export function createRemoteRuntimePtyTransport( timeoutMs ), createEnvironmentId, - connectLifecycleEpoch + epoch ) - const hostAuthorityCreate = async () => { + const hostAuthorityCreate = async (epoch = connectLifecycleEpoch) => { const keyboardOptions = await agentKeyboardOptions(createEnvironmentId) return createWithUnknownOutcomeRecovery( 'agent-session', @@ -3450,6 +3528,7 @@ export function createRemoteRuntimePtyTransport( { kind: 'explicit', ...keyboardOptions, + ...originOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, providerSession: agentProviderSessionForEnsure(resumeProviderSessionToSend), @@ -3488,24 +3567,32 @@ export function createRemoteRuntimePtyTransport( timeoutMs ), createEnvironmentId, - connectLifecycleEpoch + epoch ) } const resumeHostAuthorityCapability = resumeProviderSessionToSend ? agentResumeHostAuthorityCapability(launchAgentToSend) : undefined - const created = launchAgentToSend - ? agentSessionRequiresHostAuthorityReplay - ? await hostAuthorityCreate() - : await runRemoteAgentSessionLaunch({ - environmentId: createEnvironmentId, - hostAuthority: hostAuthorityCreate, - ...(resumeHostAuthorityCapability - ? { hostAuthorityCapability: resumeHostAuthorityCapability } - : {}), - legacy: legacyCreate - }) - : await legacyCreate() + const created = replayLaunch + ? await replayLaunch(connectLifecycleEpoch) + : launchAgentToSend + ? agentSessionRequiresHostAuthorityReplay + ? await hostAuthorityCreate() + : await runRemoteAgentSessionLaunch({ + environmentId: createEnvironmentId, + hostAuthority: () => { + replayLaunch = hostAuthorityCreate + return hostAuthorityCreate() + }, + ...(resumeHostAuthorityCapability + ? { hostAuthorityCapability: resumeHostAuthorityCapability } + : {}), + legacy: () => { + replayLaunch = legacyCreate + return legacyCreate() + } + }) + : await (replayLaunch = legacyCreate)() if (!created) { if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false @@ -3573,6 +3660,10 @@ export function createRemoteRuntimePtyTransport( } return { + ...terminalLaunchFacts(createdTerminal), + ...(createdTerminal.isReattach !== true && createdTerminal.coldRestore + ? { coldRestore: createdTerminal.coldRestore } + : {}), id: remotePtyId, replay: '', ...(authoritativePtyIncarnationId ? { incarnationId: authoritativePtyIncarnationId } : {}), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts new file mode 100644 index 00000000000..1418390a598 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' +import type { ConnectPanePtySession } from './pty-connection/connect-pane-pty-session' +import type { AgentLaunchConfigRegistryEntry } from '@/store/slices/agent-status-contract' +import { bindCaptureTransportOutputCallbacks } from './pty-connection/transport-output-callbacks' +import { bindSettlePaneSerializer } from './pty-connection/pane-serializer-settle' +import { applyTerminalLaunchResult } from './pty-connection/terminal-launch-result-policy' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame +} from '../../../../shared/terminal-stream-protocol' +import { installSavedResumeStartupCancellation } from './pty-connection/saved-resume-startup-cancellation' +import { + createRemoteRuntimeTransportMocks, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' + +const fixture = vi.hoisted(() => ({ state: {} })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => fixture.state } })) +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' +const { + runtimeCall, + subscriptionSendBinary, + latestSubscribePayload, + emitSnapshot, + inputFrameTexts, + resetRemoteRuntimeTransport +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) +beforeEach(resetRemoteRuntimeTransport) +afterEach(() => vi.clearAllMocks()) + +it.each([ + 'refused', + 'adopted', + 'adopted-D', + 'observer-only', + 'unavailable', + 'legacy', + 'ackless', + 'malformed', + 'bookkeeping-error', + 'new-C', + 'new-D', + 'stale-generation' +] as const)( + 'delivers the actual mirror %s result to the pane without replaying or clearing newer work', + async (kind) => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + const record = { + paneKey: 'web-terminal-host-tab-1:pane:1', + tabId: 'web-terminal-host-tab-1', + worktreeId: 'wt-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + connectionId: null, + state: 'done' as const, + prompt: '', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep' as const + } + const source = { cancelled: false, record } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + resumeProviderSession: record.providerSession, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const registry: Record = {} + const records = { [record.paneKey]: record } + const clearRecord = vi.fn((key: string) => { + delete records[key] + }) + fixture.state = { + agentLaunchConfigByPaneKey: registry, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: records, + automaticAgentResumeClaimsByTabId: {}, + pendingStartupByTabId: {}, + tabsByWorktree: {}, + consumeTabStartupCommand: vi.fn(() => { + if (kind === 'bookkeeping-error') { + throw new Error('bookkeeping failed') + } + }), + clearSleepingAgentSession: clearRecord + } + const note = vi.fn() + const register = vi.fn() + const callbacksReady = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real callback and cancellation installers use the supplied session members. + const session = { + transport, + pane: { id: 1, leafId: 'pane:1', container: { dataset: {} }, terminal: {} }, + deps: { + worktreeId: 'wt-1', + tabId: record.tabId, + isVisibleRef: { current: true }, + onShowSessionRestoredBanner: note, + paneTransportsRef: { current: new Map([[1, transport]]) } + }, + cacheKey: record.paneKey, + paneStartup: kind === 'observer-only' ? null : startup, + pendingStartupCommand: kind === 'observer-only' ? null : startup, + shouldDeliverStartupViaTerminalPaste: true, + startupInjectTimer: null, + transportStreamGeneration: 0, + disposed: false, + createProcessExitState: () => ({ detector: { observe: vi.fn() } }), + clearRegisteredStartupLaunchConfig: vi.fn(), + registerEffectiveLaunchConfig: register, + showSessionRestoredBanner: note, + reportRemoteRendererSerializerReady: callbacksReady, + syncHiddenRendererPtyDelivery: vi.fn(), + handleRemoteOutputPauseChanged: vi.fn(), + markHiddenOutputRestoreNeeded: vi.fn(), + beginReattachLiveDataDeferralIfUnowned: vi.fn(), + replayDataCallback: vi.fn(), + dataCallback: vi.fn() + } as unknown as ConnectPanePtySession + installSavedResumeStartupCancellation(session) + bindSettlePaneSerializer(session) + session.reportRemoteRendererSerializerReady = callbacksReady + bindCaptureTransportOutputCallbacks(session) + const output = session.captureTransportOutputCallbacks(vi.fn(), null) + const actualConfig = { agentCommand: 'codex', agentArgs: '--model actual', agentEnv: {} } + const launchResult: TerminalAttachmentLaunchResult = { + ptyId: 'physical-1', + incarnationId: 'incarnation-1', + ...(kind === 'adopted' + ? {} + : { + savedResumeDecision: { + agent: 'codex', + providerSession: + kind === 'adopted-D' ? { key: 'session_id', id: 'D' } : record.providerSession, + verdict: kind === 'unavailable' ? 'admitted' : 'chat-home' + }, + agentResumeUnavailable: true, + ...(kind === 'unavailable' ? {} : { agentResumeRefused: 'chat-home' }) + }), + ...(kind === 'adopted' || kind === 'adopted-D' || kind === 'unavailable' + ? { + launchAgent: 'codex', + launchToken: 'actual-token', + launchConfig: actualConfig + } + : {}) + } + subscriptionSendBinary.mockImplementation((bytes) => { + if (decodeTerminalStreamFrame(bytes)?.opcode !== TerminalStreamOpcode.Subscribe) { + return + } + if (kind === 'new-C' || kind === 'new-D') { + const providerSession = { + key: 'session_id' as const, + id: kind === 'new-D' ? 'D' : 'C' + } + const newer = { + ...startup, + resumeProviderSession: providerSession, + resumeSource: { cancelled: false } + } + session.paneStartup = newer + session.pendingStartupCommand = newer + registry[record.paneKey] = { launchConfig: actualConfig, identity: {}, registeredAt: 20 } + records[record.paneKey] = { ...record, providerSession, updatedAt: 20 } + } + if (kind === 'stale-generation') { + session.transportStreamGeneration += 1 + } + const { streamId, terminal } = latestSubscribePayload() + // Arrive synchronously while subscribeTerminal is still installing the stream. + if (kind === 'ackless') { + return + } + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId, + terminal, + ...(kind === 'legacy' + ? {} + : { + launchResult: + kind === 'malformed' + ? { ...launchResult, savedResumeDecision: { agent: 'codex' } } + : launchResult + }) + } + }) + }) + try { + transport.attach({ existingPtyId: 'remote:env-1@@terminal-1', callbacks: output.callbacks }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(callbacksReady).not.toHaveBeenCalled() + const { streamId } = latestSubscribePayload() + const resolvesBeforeSnapshot = runtimeCall.mock.calls.filter( + ([request]) => request.method === 'terminal.resolvePane' + ).length + transport.sendInput('ordinary-input', 'driving') + if (kind === 'ackless') { + expect(inputFrameTexts().join('')).not.toContain('ordinary-input') + } else { + await vi.waitFor(() => expect(inputFrameTexts().join('')).toContain('ordinary-input')) + } + if (kind === 'refused' || kind === 'bookkeeping-error' || kind === 'unavailable') { + expect(session.pendingStartupCommand).toBeNull() + } + emitSnapshot(streamId, 'shell$ ') + await vi.waitFor(() => expect(inputFrameTexts().join('')).toContain('ordinary-input')) + if (kind !== 'stale-generation') { + expect(callbacksReady).toHaveBeenCalledTimes(1) + } + const refused = + kind === 'refused' || kind === 'bookkeeping-error' || kind === 'new-C' || kind === 'new-D' + expect(source.cancelled).toBe(refused || kind === 'unavailable') + expect(note).toHaveBeenCalledTimes( + kind === 'refused' || + kind === 'bookkeeping-error' || + kind === 'observer-only' || + kind === 'unavailable' + ? 1 + : 0 + ) + expect(clearRecord).toHaveBeenCalledTimes( + kind === 'refused' || kind === 'unavailable' ? 1 : 0 + ) + if (kind === 'new-C' || kind === 'new-D') { + expect(session.paneStartup?.resumeSource?.cancelled).toBe(false) + expect(session.pendingStartupCommand).toBe(session.paneStartup) + expect(records[record.paneKey].updatedAt).toBe(20) + expect(register).not.toHaveBeenCalled() + } else if (kind === 'adopted' || kind === 'unavailable') { + expect(register).toHaveBeenCalledWith(actualConfig, { + launchAgent: 'codex', + launchToken: 'actual-token' + }) + } else if (kind === 'refused' || kind === 'observer-only') { + expect(session.pendingStartupCommand).toBeNull() + expect(session.paneStartup).toBeNull() + expect(register).toHaveBeenCalledWith(undefined, { + launchAgent: undefined, + launchToken: undefined + }) + } else if (kind !== 'bookkeeping-error') { + expect(register).not.toHaveBeenCalled() + } + if (kind === 'adopted-D') { + applyTerminalLaunchResult( + session, + { ...launchResult, isReattach: true }, + output.launchContext, + 'creator' + ) + expect(source.cancelled).toBe(false) + expect(session.pendingStartupCommand?.resumeSource).toBe(source) + expect(records[record.paneKey]).toBe(record) + expect(clearRecord).not.toHaveBeenCalled() + expect(register).toHaveBeenCalledWith(actualConfig, { + launchAgent: 'codex', + launchToken: 'actual-token' + }) + } + if (kind === 'refused' || kind === 'unavailable') { + // The returning creator repeats the acknowledgment; existing note idempotence survives. + applyTerminalLaunchResult(session, launchResult, output.launchContext, 'creator') + expect(note).toHaveBeenCalledTimes(1) + } + expect(inputFrameTexts().join('')).not.toContain('codex resume C') + expect( + runtimeCall.mock.calls.filter(([request]) => request.method === 'terminal.resolvePane') + ).toHaveLength(resolvesBeforeSnapshot) + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe( + false + ) + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.send')).toBe( + false + ) + } finally { + transport.disconnect() + } + } +) + +it.each(['subscription', 'recovery'] as const)( + 'rejects predecessor %s facts while a successor connect owns the callback bundle', + async (operation) => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + const predecessorResult = vi.fn() + const successorResult = vi.fn() + const launchResult: TerminalAttachmentLaunchResult = { + ptyId: 'physical-1', + incarnationId: 'incarnation-1', + agentResumeRefused: 'chat-home', + savedResumeDecision: { + agent: 'codex', + providerSession: { key: 'session_id', id: 'C' }, + verdict: 'chat-home' + } + } + let finishRecovery: (value: unknown) => void = () => {} + const recoveryResult = new Promise((resolve) => { + finishRecovery = resolve + }) + let finishActivation: (value: unknown) => void = () => {} + const activationResult = new Promise((resolve) => { + finishActivation = resolve + }) + try { + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + callbacks: { onLaunchResult: predecessorResult } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId, terminal } = latestSubscribePayload() + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation((request: { method: string }) => { + if (request.method === 'terminal.recoverPane') { + return recoveryResult + } + if (request.method === 'session.tabs.activate') { + return activationResult + } + return defaultCall?.(request) + }) + if (operation === 'recovery') { + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'error', streamId, message: 'SSH_SESSION_EXPIRED: relay changed' } + }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.recoverPane' }) + ) + ) + } + const successor = transport.connect({ + url: '', + callbacks: { onLaunchResult: successorResult } + }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.activate' }) + ) + ) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'subscribed', streamId, terminal, launchResult } + }) + finishRecovery({ + ok: true, + result: { terminal: { handle: terminal, ...launchResult } } + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(predecessorResult).not.toHaveBeenCalled() + expect(successorResult).not.toHaveBeenCalled() + transport.disconnect() + finishActivation({ ok: true, result: {} }) + await successor + } finally { + transport.disconnect() + finishRecovery({ ok: true, result: {} }) + finishActivation({ ok: true, result: {} }) + } + } +) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 16e50bce1b5..fe9d365c748 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -1,16 +1,48 @@ import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import type { AgentStatusClearIpcPayload, - AgentStatusIpcPayload + AgentStatusIpcPayload, + RefusedPaneResume } from '../../../../shared/agent-status-types' import { resolveLegacyWorkerTerminalRecoveryAction, rollbackLegacyWorkerTerminalSurfaceInStore } from '../legacy-worker-terminal-recovery-event' import { useAppStore } from '../../store' +import { forgetRefusedPaneResume, namesRefusedResume } from '@/lib/forget-refused-pane-resume' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import { resolvePaneKey } from './agent-status-routing' import type { PendingAgentStatusEvent } from './agent-status-bridge-types' +/** An already delivered event for the pane naming a refused conversation would restore it. */ +function dropEventsRestoringRefused( + paneKey: string, + refused: RefusedPaneResume, + queues: { + pendingAgentStatusEvents: PendingAgentStatusEvent[] + liveAgentStatusBurstQueue: AgentStatusIpcPayload[] + } +): void { + const restores = (event: AgentStatusIpcPayload): boolean => + !!refused.expectedStatus && + event.receivedAt <= refused.expectedStatus.receivedAt && + event.stateStartedAt === refused.expectedStatus.stateStartedAt && + resolveAgentPaneAuthorityKey(event.paneKey) === paneKey && + namesRefusedResume(refused, { ...event, agent: event.agentType }) + const pending = queues.pendingAgentStatusEvents + for (let index = pending.length - 1; index >= 0; index -= 1) { + if (restores(pending[index].data)) { + pending.splice(index, 1) + } + } + const burst = queues.liveAgentStatusBurstQueue + for (let index = burst.length - 1; index >= 0; index -= 1) { + if (restores(burst[index])) { + burst.splice(index, 1) + } + } +} + export function registerAgentStatusListeners(args: { unsubs: (() => void)[] enqueueLiveAgentStatus: (data: AgentStatusIpcPayload) => void @@ -72,6 +104,14 @@ export function registerAgentStatusListeners(args: { if (!('paneKey' in data) || typeof data.paneKey !== 'string') { return } + if (data.refusedResume) { + dropEventsRestoringRefused(data.paneKey, data.refusedResume, { + pendingAgentStatusEvents, + liveAgentStatusBurstQueue + }) + forgetRefusedPaneResume(useAppStore.getState(), data.paneKey, data.refusedResume) + return + } // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { drainQueuedLiveAgentStatusesForPane(data.paneKey) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts new file mode 100644 index 00000000000..e9cc5a5dd12 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts @@ -0,0 +1,155 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { + AgentStatusIpcPayload, + RefusedPaneResume +} from '../../../../shared/agent-status-types' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '@/store/slices/store-session-test-harness' +import { registerAgentStatusListeners } from './agent-status-listeners' + +const bridge = vi.hoisted(() => ({ getState: vi.fn(), setState: vi.fn() })) +vi.mock('@/store', () => ({ useAppStore: bridge })) + +afterEach(() => vi.unstubAllGlobals()) + +it.each([10, 100])( + 'counts refused-clear queue moves and real store notifications at %i entries', + (size) => { + vi.stubGlobal('window', undefined) + createStoreSessionMockApi() + const store = createTestStore() + bridge.getState.mockImplementation(store.getState) + bridge.setState.mockImplementation(store.setState) + const refused: RefusedPaneResume = { + agent: 'codex', + providerSession: { key: 'session_id', id: 'conversation' }, + expectedStatus: { paneKey: 'tab:leaf', receivedAt: 10, stateStartedAt: 1 }, + expectedSavedRecord: { paneKey: 'tab:leaf', capturedAt: 1, updatedAt: 1 } + } + const event = (index: number): AgentStatusIpcPayload => ({ + paneKey: index % 2 ? `other-${index}` : 'tab:leaf', + connectionId: null, + agentType: 'codex', + providerSession: refused.providerSession, + state: 'done', + prompt: '', + receivedAt: 10, + stateStartedAt: 1 + }) + let reads = 0 + let writes = 0 + const countQueue = (entries: T[]): T[] => { + entries.forEach((entry, index) => + Object.defineProperty(entries, index, { + configurable: true, + enumerable: true, + get: () => { + reads += 1 + return entry + }, + set: (next: T) => { + writes += 1 + entry = next + } + }) + ) + return entries + } + let copied = 0 + const countMap = (entries: T): T => + new Proxy(entries, { + ownKeys(target) { + const keys = Reflect.ownKeys(target) + copied += keys.length + return keys + } + }) + store.setState({ + agentStatusByPaneKey: countMap( + Object.fromEntries( + Array.from({ length: size }, (_, index) => { + const row = event(index) + return [ + index === 0 ? 'tab:leaf' : `other-${index}`, + { ...row, updatedAt: 10, stateHistory: [] } + ] + }) + ) + ), + sleepingAgentSessionsByPaneKey: countMap( + Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + index === 0 ? 'tab:leaf' : `other-${index}`, + { + paneKey: index === 0 ? 'tab:leaf' : `other-${index}`, + worktreeId: 'wt', + agent: 'codex', + providerSession: refused.providerSession, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1 + } + ]) + ) + ) + }) + const notifications = vi.fn() + const unsubscribe = store.subscribe(notifications) + const pending = countQueue( + Array.from({ length: size }, (_, index) => ({ + data: event(index), + firstSeenAt: 0, + replay: false + })) + ) + const burst = countQueue(Array.from({ length: size }, (_, index) => event(index))) + // Newer same-conversation work must survive the old refusal too. + burst[size - 1] = { ...event(0), receivedAt: 11 } + const onClear = vi.fn>(() => () => {}) + vi.stubGlobal('window', { + ...window, + api: { + ...window.api, + agentStatus: { + onSet: vi.fn(() => () => {}), + onClear + } + } + }) + const unsubs: (() => void)[] = [] + registerAgentStatusListeners({ + unsubs, + pendingAgentStatusEvents: pending, + liveAgentStatusBurstQueue: burst, + transientClearWatermarkByConnectionId: new Map(), + enqueueLiveAgentStatus: vi.fn(), + drainQueuedLiveAgentStatusesForPane: vi.fn() + }) + const clear = onClear.mock.calls[0]![0] + reads = 0 + writes = 0 + copied = 0 + try { + clear({ paneKey: 'tab:leaf', refusedResume: refused }) + const removed = size / 2 + // Reverse splice shifts 1+…+m surviving entries in EACH queue, besides one predicate read per row. + expect([reads, writes]).toEqual([3 * size + removed * (removed + 1), removed * (removed + 1)]) + expect(copied).toBe(2 * size) + expect(notifications).toHaveBeenCalledTimes(2) + expect([pending.length, burst.length]).toEqual([removed, removed]) + expect(burst.at(-1)?.receivedAt).toBe(11) + expect(store.getState().agentStatusByPaneKey['tab:leaf']).toBeUndefined() + expect(Object.keys(store.getState().sleepingAgentSessionsByPaneKey)).toHaveLength(size - 1) + notifications.mockClear() + copied = 0 + clear({ paneKey: 'tab:leaf', refusedResume: refused }) + expect(notifications).not.toHaveBeenCalled() + expect(copied).toBe(0) + } finally { + unsubs.forEach((dispose) => dispose()) + unsubscribe() + onClear.mockRestore() + } + } +) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8000b87e222..b3f6c4cf60d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3164,6 +3164,8 @@ "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenElsewhere": "Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.", + "conversationOpenInTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25e43028de4..395ab9b8baa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18979,6 +18979,8 @@ "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenElsewhere": "Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.", + "conversationOpenInTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", @@ -19468,8 +19470,8 @@ "showTerminal": "Show terminal" }, "resumeInNewChat": { - "exitUnconfirmedFromTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then open this conversation from Session History.", - "exitUnconfirmed": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + "exitUnconfirmedFromTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.", + "exitUnconfirmed": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.", "notSavedYet": "{{agent}} hasn't saved this conversation yet. Try again after it replies.", "cannotResume": "This {{agent}} conversation can't be resumed in a native chat." }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7a41bbdfe25..def963e176f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18644,6 +18644,8 @@ "claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.", + "conversationOpenElsewhere": "Codex sigue manteniendo abierta esta conversación. Inténtalo de nuevo en un momento o cierra otras terminales de Codex.", + "conversationOpenInTerminal": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal para continuar aquí.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", @@ -19034,8 +19036,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} aún no ha guardado esta conversación. Inténtalo de nuevo cuando responda.", "cannotResume": "Esta conversación de {{agent}} no se puede reanudar en un chat nativo.", - "exitUnconfirmed": "Orca no puede confirmar que {{agent}} haya salido de su terminal. Detenga lo que se está ejecutando allí o cierre la terminal y vuelva a intentarlo.", - "exitUnconfirmedFromTerminal": "Orca no puede confirmar que {{agent}} haya salido de su terminal. Detenga lo que se está ejecutando allí o cierre la terminal, luego abra esta conversación desde el Historial de sesiones." + "exitUnconfirmed": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal; luego abre esta conversación desde el Historial de sesiones.", + "exitUnconfirmedFromTerminal": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal; luego abre esta conversación desde el Historial de sesiones." }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f3b4a70f3b9..3c33d190330 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18784,6 +18784,8 @@ "claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.", + "conversationOpenElsewhere": "Codex maintient encore cette conversation ouverte. Réessayez dans un instant ou fermez les autres terminaux Codex.", + "conversationOpenInTerminal": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal pour continuer ici.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", @@ -19174,8 +19176,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} n'a pas encore enregistré cette conversation. Réessayez après sa réponse.", "cannotResume": "Cette conversation {{agent}} ne peut pas être reprise dans un chat natif.", - "exitUnconfirmedFromTerminal": "Orca ne peut pas confirmer que {{agent}} s’est arrêté dans son terminal. Arrêtez ce qui s’y exécute ou fermez le terminal, puis ouvrez cette conversation depuis l’historique des sessions.", - "exitUnconfirmed": "Orca ne peut pas confirmer que {{agent}} s’est arrêté dans son terminal. Arrêtez ce qui s’y exécute ou fermez le terminal, puis réessayez." + "exitUnconfirmedFromTerminal": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal, puis ouvrez cette conversation depuis l’historique des sessions.", + "exitUnconfirmed": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal, puis ouvrez cette conversation depuis l’historique des sessions." }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d2524c04625..8b2ee668ebb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18674,6 +18674,8 @@ "claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。", + "conversationOpenElsewhere": "Codex がまだこの会話を開いたままにしています。少し待ってからもう一度試すか、ほかの Codex ターミナルを閉じてください。", + "conversationOpenInTerminal": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じて、ここで続けてください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", @@ -19064,8 +19066,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} はまだこの会話を保存していません。応答してから再度お試しください。", "cannotResume": "この {{agent}} の会話はネイティブチャットで再開できません。", - "exitUnconfirmedFromTerminal": "Orca は {{agent}} がターミナルで終了したことを確認できません。実行中の処理を停止するかターミナルを閉じてから、セッション履歴でこの会話を開いてください。", - "exitUnconfirmed": "Orca は、{{agent}} がターミナルで終了したことを確認できません。そこで実行されているものを停止するか、ターミナルを閉じてから、もう一度試してください。" + "exitUnconfirmedFromTerminal": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じてから、セッション履歴でこの会話を開いてください。", + "exitUnconfirmed": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じてから、セッション履歴でこの会話を開いてください。" }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3caeb07a979..175c45fb7a0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18674,6 +18674,8 @@ "claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.", + "conversationOpenElsewhere": "Codex가 아직 이 대화를 열어 두고 있습니다. 잠시 후 다시 시도하거나 다른 Codex 터미널을 닫으세요.", + "conversationOpenInTerminal": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 후 여기서 계속하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", @@ -19064,8 +19066,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}}이(가) 아직 이 대화를 저장하지 않았습니다. 응답한 후 다시 시도하세요.", "cannotResume": "이 {{agent}} 대화는 기본 채팅에서 재개할 수 없습니다.", - "exitUnconfirmedFromTerminal": "Orca가 터미널에서 {{agent}} 종료 여부를 확인할 수 없습니다. 실행 중인 프로세스를 중지하거나 터미널을 닫은 후 세션 기록에서 이 대화를 여세요.", - "exitUnconfirmed": "Orca가 터미널에서 {{agent}} 종료 여부를 확인할 수 없습니다. 실행 중인 프로세스를 중지하거나 터미널을 닫은 후 다시 시도하세요." + "exitUnconfirmedFromTerminal": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 다음 세션 기록에서 이 대화를 여세요.", + "exitUnconfirmed": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 다음 세션 기록에서 이 대화를 여세요." }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 51ff735797f..331764ca2ee 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19778,8 +19778,8 @@ }, "transcriptLabel": "Conversa", "resumeInNewChat": { - "exitUnconfirmedFromTerminal": "O Orca não consegue confirmar se o {{agent}} foi encerrado no terminal. Pare o que estiver em execução lá ou feche o terminal e abra esta conversa no Histórico de sessões.", - "exitUnconfirmed": "O Orca não consegue confirmar se o {{agent}} foi encerrado no terminal. Pare o que estiver em execução lá ou feche o terminal e tente novamente.", + "exitUnconfirmedFromTerminal": "{{agent}} já está executando esta conversa em outro terminal. Encerre o {{agent}} lá ou feche esse terminal e abra esta conversa no Histórico de sessões.", + "exitUnconfirmed": "{{agent}} já está executando esta conversa em outro terminal. Encerre o {{agent}} lá ou feche esse terminal e abra esta conversa no Histórico de sessões.", "notSavedYet": "O {{agent}} ainda não salvou esta conversa. Tente novamente depois que ele responder.", "cannotResume": "Esta conversa do {{agent}} não pode ser retomada em um chat nativo." }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e74636f3062..72bbea2fdfd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18639,6 +18639,8 @@ "claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。", + "conversationOpenElsewhere": "Codex 仍在保持此对话打开。请稍后重试,或关闭其他 Codex 终端。", + "conversationOpenInTerminal": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后在这里继续。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", @@ -19029,8 +19031,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} 尚未保存此对话。请在它回复后重试。", "cannotResume": "此 {{agent}} 对话无法在本机聊天中恢复。", - "exitUnconfirmed": "Orca 无法确认 {{agent}} 已在其终端中退出。停止那里正在运行的程序或关闭终端,然后重试。", - "exitUnconfirmedFromTerminal": "Orca 无法确认 {{agent}} 已在其终端中退出。停止那里正在运行的程序或关闭终端,然后从会话历史记录中打开此对话。" + "exitUnconfirmed": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后从会话历史记录中打开此对话。", + "exitUnconfirmedFromTerminal": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后从会话历史记录中打开此对话。" }, "state": { "loading": { diff --git a/src/renderer/src/lib/cli-conversation-exit-guidance.ts b/src/renderer/src/lib/cli-conversation-exit-guidance.ts index 41da962ac58..fd50d3cb552 100644 --- a/src/renderer/src/lib/cli-conversation-exit-guidance.ts +++ b/src/renderer/src/lib/cli-conversation-exit-guidance.ts @@ -11,12 +11,12 @@ export function cliConversationExitGuidance( return fromTerminal ? translate( 'components.native-chat.resumeInNewChat.exitUnconfirmedFromTerminal', - "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then open this conversation from Session History.", + '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.', values ) : translate( 'components.native-chat.resumeInNewChat.exitUnconfirmed', - "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.', values ) } diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts new file mode 100644 index 00000000000..7372577bbce --- /dev/null +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -0,0 +1,66 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { RefusedPaneResume } from '../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' + +/** Whether a saved claim names the refused conversation. Only local claims can: the host judged + * its own chats, and a remote host's same id is a different conversation. */ +export function namesRefusedResume( + refused: RefusedPaneResume, + claim: { + agent?: string + providerSession?: AgentProviderSessionMetadata + connectionId?: string | null + } +): boolean { + return ( + !claim.connectionId && + claim.agent === refused.agent && + agentProviderSessionsEqual(refused.agent, claim.providerSession, refused.providerSession) + ) +} + +/** + * Main refused `paneKey`'s saved resume because a native chat owns that conversation. Forgets it as + * this pane's, whether or not the pane is mounted, so nothing offers or retries it here. Another + * pane holding the same conversation is refused and forgotten at its own restore. Main already + * forgot its own copy, so nothing is sent back. + */ +export function forgetRefusedPaneResume( + state: AppState, + paneKey: string, + refused: RefusedPaneResume +): void { + const expected = refused.expectedSavedRecord + const saved = expected && state.sleepingAgentSessionsByPaneKey[expected.paneKey] + if ( + expected && + saved && + saved.capturedAt === expected.capturedAt && + saved.updatedAt === expected.updatedAt && + namesRefusedResume(refused, saved) + ) { + state.clearSleepingAgentSession(expected.paneKey) + } + const identity = refused.expectedStatus + if (!identity) { + return + } + useAppStore.setState((current) => { + const live = current.agentStatusByPaneKey[paneKey] + if ( + !live || + live.updatedAt > identity.receivedAt || + live.stateStartedAt !== identity.stateStartedAt || + !namesRefusedResume(refused, { ...live, agent: live.agentType }) + ) { + return current + } + const next = { ...current.agentStatusByPaneKey } + delete next[paneKey] + return { agentStatusByPaneKey: next } + }) +} diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts index 7725700d97a..4bcc6f7015c 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts @@ -22,6 +22,7 @@ vi.mock('@/store', () => ({ sshConnectionStates: new Map(), sshStateByEnvironment: new Map(), runtimeStatusByEnvironmentId: new Map(), + agentStatusByPaneKey: {}, tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, openFiles: [], browserTabsByWorktree: {}, diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts index 2f249d9b2af..c39a3b4fba2 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -6,6 +6,7 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-r const mockCreateTab = vi.fn() const store = { + agentStatusByPaneKey: {}, settings: { agentCmdOverrides: {}, agentDefaultArgs: {} as Record, diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 5df77c13dc2..67b9a2bbf8e 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -78,6 +78,20 @@ export function launchSleepingAgentSession( ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, resumeProviderSession: record.providerSession, + resumeOrigin: 'saved', + resumeSource: { + cancelled: false, + record, + ...(state.agentStatusByPaneKey[record.paneKey] + ? { + expectedStatus: { + paneKey: record.paneKey, + receivedAt: state.agentStatusByPaneKey[record.paneKey].updatedAt, + stateStartedAt: state.agentStatusByPaneKey[record.paneKey].stateStartedAt + } + } + : {}) + }, launchAgent: record.agent, ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), ...(startupPlan.startupCommandDelivery diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index cbbe6b4d0e3..ffd34af378a 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -407,14 +407,14 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch.defaultTerminalTabsAppliedByWorktreeId).toBeUndefined() }) - it('keeps sleeping agent session clearing keys in patches', () => { + it('clears the last sleeping agent session with an empty map the host split keeps', () => { const patch = buildWorkspaceSessionPatch( createSnapshot({ sleepingAgentSessionsByPaneKey: {} }), ['sleepingAgentSessionsByPaneKey'] ) - expect(Object.hasOwn(patch, 'sleepingAgentSessionsByPaneKey')).toBe(true) - expect(patch.sleepingAgentSessionsByPaneKey).toBeUndefined() + // undefined would be dropped before the write, leaving the record on disk until quit. + expect(patch.sleepingAgentSessionsByPaneKey).toEqual({}) }) it('keeps a staged browser tab out of incremental patches too', () => { diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 38d3b008802..61599782ab4 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -200,8 +200,9 @@ export function buildWorkspaceSessionPatch( : undefined } if (changed.has('sleepingAgentSessionsByPaneKey')) { + // Why `{}`: the host split drops undefined, so clearing the last record never reached disk. patch.sleepingAgentSessionsByPaneKey = - buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey + buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey ?? {} } return patch diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index ddb32e7dfd7..86204f37f27 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -1,3 +1,4 @@ +import type { TerminalAttachmentLaunchResult } from '../../../shared/terminal-attachment-launch-result' import type { TerminalSnapshotUnavailableReason } from '../../../shared/terminal-snapshot-unavailability' import type { TerminalStreamEndVerdict } from '../../../shared/terminal-stream-end-verdict' import type { TerminalInputAckKind } from '../../../shared/terminal-stream-protocol' @@ -12,6 +13,8 @@ export type TerminalMultiplexEvent = | { type: 'ready' } | { type: 'subscribed' + terminal?: string + launchResult?: unknown streamId: number streamGeneration?: string capabilities?: { @@ -58,7 +61,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { ) => void onSubscribed?: () => void /** The host installed the stream and named its capabilities; its initial snapshot is still on the way. */ - onSubscribeAcknowledged?: () => void + onSubscribeAcknowledged?: (result?: TerminalAttachmentLaunchResult) => void onOutputPauseCapability?: () => void /** The host discarded output while this stream was paused (negotiated streams only). */ onPausedOutputDropped?: () => void diff --git a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts index cc2aacb43fb..3d96f0ce111 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts @@ -1,3 +1,4 @@ +import { parseTerminalAttachmentLaunchResult } from '../../../shared/terminal-attachment-launch-result' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR } from '../../../shared/terminal-multiplex-flow-control' import { parseTerminalStreamEndVerdict } from '../../../shared/terminal-stream-end-verdict' @@ -99,7 +100,11 @@ export abstract class RemoteRuntimeTerminalResponseController extends RemoteRunt stream.callbacks.onOutputPauseCapability?.() } if (!stream.initialSnapshotReceived) { - stream.callbacks.onSubscribeAcknowledged?.() + stream.callbacks.onSubscribeAcknowledged?.( + event.terminal === stream.terminal + ? parseTerminalAttachmentLaunchResult(event.launchResult) + : undefined + ) } } else if (event.type === 'end') { disposeRemoteTerminalStreamState(stream, 'Remote terminal stream ended.') diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 22253998daf..5717d1e9b55 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -208,6 +208,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null @@ -234,6 +235,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index c1bbfe88aa1..b012993af3e 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -6,6 +6,7 @@ import type { } from '../../../../shared/workspace-layout/workspace-layout-published' import type { TuiAgent } from '../../../../shared/tui-agent' import type { + SavedResumeStartupSource, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -63,6 +64,8 @@ export type TerminalState = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' + resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index e80cad3b5e6..1c935d6d911 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -78,6 +78,10 @@ export const AGENT_SESSION_FAILURE_COPY = { "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", runtimeNotInstalled: "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + conversationOpenElsewhere: + 'Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.', + conversationOpenInTerminal: + '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.', chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 86e90aafd8e..c9bd5cd5189 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -188,6 +188,8 @@ const FAILURE_SENTENCES = { claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'), agentCommandNotRunnable: namingAgent('agentCommandNotRunnable'), runtimeNotInstalled: namingAgent('runtimeNotInstalled'), + conversationOpenInTerminal: namingAgent('conversationOpenInTerminal'), + conversationOpenElsewhere: (_context, _fact, _surface, say) => say('conversationOpenElsewhere'), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index a42009fd8f0..13705c9c1d1 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -40,6 +40,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'claudeAccountSetupFailed', 'agentCommandNotRunnable', 'runtimeNotInstalled', + 'conversationOpenInTerminal', + 'conversationOpenElsewhere', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts index 571cda3d6fb..3f9eeed00ba 100644 --- a/src/shared/agent-session-host-authority.ts +++ b/src/shared/agent-session-host-authority.ts @@ -105,6 +105,7 @@ export type RuntimeEnsureAgentSessionRequest = } | { kind: 'explicit' + resumeOrigin?: 'saved' worktree: string agent: ResumableTuiAgent providerSession: AgentProviderSessionMetadata diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 4ba2855bb42..6f9b6734161 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -85,6 +85,9 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'agentCommandNotRunnable', /** The runtime Orca installs for the agent is not installed on this host. */ 'runtimeNotInstalled', + /** A terminal may still be writing the chat's conversation, so its agent did not start. */ + 'conversationOpenInTerminal', + 'conversationOpenElsewhere', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index c2ce2cadab1..8a5b597e411 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -116,6 +116,8 @@ const REASON_WORDS = { claudeAccountSetupFailed: { fact: 'claudeAccountSetupFailed', action: 'retry' }, agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, + conversationOpenInTerminal: { fact: 'conversationOpenInTerminal', action: 'actFirst' }, + conversationOpenElsewhere: { fact: 'conversationOpenElsewhere', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 393cf55d971..f6a5a71158d 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -1,4 +1,5 @@ import type { AgentHookSource } from './agent-hook-relay' +import type { AgentStatusCacheIdentity, RefusedPaneResume } from './agent-status-ipc-payload' import type { AgentStatusState } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' import type { TuiAgent } from './tui-agent' @@ -307,3 +308,45 @@ export function extractAgentProviderSession( // Preserve the existing import path. export { getAgentForkArgv, getAgentResumeArgv } from './agent-resume-argv' + +/** A host home check for this exact saved continuation, not a terminal writer claim. */ +export type SavedResumeDecision = { + agent: string + providerSession: AgentProviderSessionMetadata + verdict: 'admitted' | 'chat-home' | 'chat-writing' | 'unverifiable' +} + +/** Effective facts of a terminal launch; a refused shell carries no rejected agent metadata. */ +export type TerminalLaunchResult = { + savedResumeDecision?: SavedResumeDecision + agentResumeUnavailable?: true + agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' + refusedResume?: RefusedPaneResume + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + launchToken?: string + resumeProviderSession?: AgentProviderSessionMetadata +} + +/** Runtime-only source shared by copies of one saved startup. Never persisted or sent to a host. */ +export type SavedResumeStartupSource = { + cancelled: boolean + record?: SleepingAgentSessionRecord + expectedStatus?: AgentStatusCacheIdentity +} + +/** Forward only effective launch facts through each existing terminal envelope. */ +export function terminalLaunchFacts(result: TerminalLaunchResult): TerminalLaunchResult { + return { + ...(result.savedResumeDecision ? { savedResumeDecision: result.savedResumeDecision } : {}), + ...(result.agentResumeUnavailable + ? { agentResumeUnavailable: result.agentResumeUnavailable } + : {}), + ...(result.agentResumeRefused ? { agentResumeRefused: result.agentResumeRefused } : {}), + ...(result.refusedResume ? { refusedResume: result.refusedResume } : {}), + ...(result.launchConfig ? { launchConfig: result.launchConfig } : {}), + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + ...(result.launchToken ? { launchToken: result.launchToken } : {}), + ...(result.resumeProviderSession ? { resumeProviderSession: result.resumeProviderSession } : {}) + } +} diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 6885df76d33..09a495879fe 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -85,6 +85,14 @@ export type AgentStatusCacheIdentity = { stateStartedAt: number } +/** A pane's saved resume the host refused because a native chat is that conversation's home. */ +export type RefusedPaneResume = { + expectedStatus?: AgentStatusCacheIdentity + expectedSavedRecord?: { paneKey: string; capturedAt: number; updatedAt: number } + agent: string + providerSession: AgentProviderSessionMetadata +} + /** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ export type AgentStatusClearIpcPayload = | { @@ -92,6 +100,8 @@ export type AgentStatusClearIpcPayload = statusUnavailable?: true /** An OSC 7501 program took back its own row, so even a `done` row goes. */ programStatusRemoved?: true + /** Forget that conversation as this pane's resume target too, not only its status. */ + refusedResume?: RefusedPaneResume } | { transient: true diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0e2ec77f74c..109fac80b9d 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -41,7 +41,8 @@ export type { AgentStatusCacheIdentity, AgentStatusClearIpcPayload, AgentStatusIpcPayload, - MigrationUnsupportedPtyEntry + MigrationUnsupportedPtyEntry, + RefusedPaneResume } from './agent-status-ipc-payload' export { mainAgentStatusEqual, type AgentMainAgentStatus } from './main-agent-status' export { AGENT_STATE_HISTORY_MAX, type AgentStateHistoryEntry } from './agent-state-history' diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 6b3b6f19f54..98cd8be9dda 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -119,7 +119,11 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ] as const // Each area lists what it publishes in its own module; a new capability edits only that module. +export const SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY = + 'agent-session.saved-resume-origin.v1' as const + export const RUNTIME_CAPABILITIES = [ + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, ...HOST_RUNTIME_CAPABILITIES, ...DEVICE_KEY_RUNTIME_CAPABILITIES, ...MOBILE_RUNTIME_CAPABILITIES, diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index b93eaccfdea..15a98d809e6 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -129,6 +129,7 @@ export const AutomaticEnsure = z export const ExplicitEnsure = z .object({ kind: z.literal('explicit'), + resumeOrigin: z.literal('saved').optional(), worktree: WorktreeSelector, agent: z.enum(RESUMABLE_TUI_AGENTS), providerSession: ProviderSession, diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index 5a27ab652c0..4c665a5b736 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -211,6 +211,8 @@ export const TerminalCreateParams = z.object({ }) .optional(), resumeProviderSession: ResumeProviderSessionParam.optional(), + // Older hosts drop it and resume as before. + resumeOrigin: z.literal('saved').optional(), launchToken: OptionalString, launchAgent: z.string().refine(isTuiAgent).optional(), terminalKittyKeyboardProtocol: z.boolean().optional(), diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 005d3e5e650..630865ca5b1 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { TerminalLaunchResult } from './agent-session-resume' import type * as AgentSessionResume from './agent-session-resume' import type { StartupCommandDelivery } from './codex-startup-delivery' import type { ExecutionHostId } from './execution-host' @@ -5,7 +6,6 @@ import type { PtyIncarnationId } from './pty-incarnation' import type { PtyLivenessVerdict } from './pty-liveness-verdict' import type { RuntimeListingHostScope } from './runtime-listing-host-scope' import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts' -import type { StartupCwdFallback } from './startup-cwd-fallback' import type { TabGroupLayoutNode } from './tab-types' import type { TerminalExitCause } from './terminal-exit-cause' import type { TerminalPaneLayoutNode } from './terminal-tab-types' @@ -268,28 +268,7 @@ export type RuntimeTerminalCreateRequestPayload = source: 'runtime-session' }) -export type RuntimeTerminalCreate = { - handle: string - /** Host-owned PTY incarnation used to fence remote identity observations. */ - incarnationId?: string | null - tabId?: string - paneKey?: string | null - ptyId?: string | null - worktreeId: string - title: string | null - executionHostId?: ExecutionHostId - hostPlatform?: NodeJS.Platform - surface?: 'background' | 'visible' - warning?: string - agentSessionDisposition?: 'created' | 'adopted' - isReattach?: true - /** Spawn process identity for host-internal ownership proof. */ - processId?: number - /** Host-internal: the requested folder was gone, so the process started at the workspace root. */ - startupCwdFallback?: StartupCwdFallback - /** Host-internal saved-folder decision; excluded by RPC result projection. */ - savedStartupCwd?: string -} +export type { RuntimeTerminalCreate } from './runtime-terminal-create-contract' export type RuntimeTerminalSplit = { handle: string @@ -299,7 +278,7 @@ export type RuntimeTerminalSplit = { leafId?: string } -export type RuntimeTerminalResolvePane = { +export type RuntimeTerminalResolvePane = TerminalLaunchResult & { handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string | null diff --git a/src/shared/runtime-terminal-create-contract.ts b/src/shared/runtime-terminal-create-contract.ts new file mode 100644 index 00000000000..e4fc3e55ca5 --- /dev/null +++ b/src/shared/runtime-terminal-create-contract.ts @@ -0,0 +1,27 @@ +import type { TerminalLaunchResult } from './agent-session-resume' +import type { ExecutionHostId } from './execution-host' +import type { StartupCwdFallback } from './startup-cwd-fallback' + +export type RuntimeTerminalCreate = TerminalLaunchResult & { + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + handle: string + /** Host-owned PTY incarnation used to fence remote identity observations. */ + incarnationId?: string | null + tabId?: string + paneKey?: string | null + ptyId?: string | null + worktreeId: string + title: string | null + executionHostId?: ExecutionHostId + hostPlatform?: NodeJS.Platform + surface?: 'background' | 'visible' + warning?: string + agentSessionDisposition?: 'created' | 'adopted' + isReattach?: true + /** Spawn process identity for host-internal ownership proof. */ + processId?: number + /** Host-internal: the requested folder was gone, so the process started at the workspace root. */ + startupCwdFallback?: StartupCwdFallback + /** Host-internal saved-folder decision; excluded by RPC result projection. */ + savedStartupCwd?: string +} diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 8e2c5bdf198..2ca8308cac6 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -80,6 +80,8 @@ const KIND_CATEGORY = { claudeAccountSetupFailed: 'startFailed', agentCommandNotRunnable: 'startFailed', runtimeNotInstalled: 'startFailed', + conversationOpenInTerminal: 'startFailed', + conversationOpenElsewhere: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -113,6 +115,8 @@ const KIND_VERDICT = { claudeAccountSetupFailed: 'failure', agentCommandNotRunnable: 'failure', runtimeNotInstalled: 'failure', + conversationOpenInTerminal: 'failure', + conversationOpenElsewhere: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', diff --git a/src/shared/terminal-attachment-launch-result.ts b/src/shared/terminal-attachment-launch-result.ts new file mode 100644 index 00000000000..21a050d9b52 --- /dev/null +++ b/src/shared/terminal-attachment-launch-result.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import type { TerminalLaunchResult } from './agent-session-resume' +import { isTuiAgent } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' +import { ResumeProviderSessionParam } from './rpc-contract/terminal-unary-params' +import { sleepingAgentLaunchConfigBaseSchema } from './workspace-session-sleeping-agents' + +export type TerminalAttachmentLaunchResult = TerminalLaunchResult & { + ptyId: string + incarnationId: string +} + +const launchResultSchema = z.object({ + ptyId: z.string().min(1), + incarnationId: z.string().min(1), + savedResumeDecision: z + .object({ + agent: z.string().min(1), + providerSession: ResumeProviderSessionParam, + verdict: z.enum(['admitted', 'chat-home', 'chat-writing', 'unverifiable']) + }) + .optional(), + agentResumeUnavailable: z.literal(true).optional(), + agentResumeRefused: z.enum(['chat-home', 'chat-writing', 'unverifiable']).optional(), + refusedResume: z + .object({ + agent: z.string().min(1), + providerSession: ResumeProviderSessionParam, + expectedStatus: z + .object({ + paneKey: z.string().min(1), + receivedAt: z.number().finite(), + stateStartedAt: z.number().finite() + }) + .optional(), + expectedSavedRecord: z + .object({ + paneKey: z.string().min(1), + capturedAt: z.number().finite(), + updatedAt: z.number().finite() + }) + .optional() + }) + .optional(), + launchConfig: sleepingAgentLaunchConfigBaseSchema.optional(), + launchAgent: z.custom(isTuiAgent).optional(), + launchToken: z.string().min(1).optional(), + resumeProviderSession: ResumeProviderSessionParam.optional() +}) + +export function parseTerminalAttachmentLaunchResult( + value: unknown +): TerminalAttachmentLaunchResult | undefined { + const result = launchResultSchema.safeParse(value) + return result.success ? result.data : undefined +} diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index e8fe4aaf735..a6bfc11a8d0 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -63,7 +63,7 @@ const sleepingAgentLaunchEnvSchema = z.preprocess( z.record(z.string(), z.string()) ) -const sleepingAgentLaunchConfigBaseSchema = z.object({ +export const sleepingAgentLaunchConfigBaseSchema = z.object({ agentCommand: z.string().optional(), agentArgs: z.string(), agentEnv: sleepingAgentLaunchEnvSchema, diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts index edb2eb973ff..45a9ee7f9ab 100644 --- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts +++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts @@ -108,6 +108,8 @@ export function createHostTerminalRuntimeStub( const runtime: Record = { getRuntimeId: () => 'cross-version-host', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, resolveLiveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null), resolveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null), registerRemoteTerminalViewSubscriber: () => () => {}, diff --git a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts index 5d5570b7b4b..0b6df14998a 100644 --- a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts +++ b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts @@ -117,7 +117,8 @@ async function hostSurfaceStatus( page: Page, environmentId: string, worktreeId: string, - parentTabId: string + parentTabId: string, + expectedLeafId?: string ): Promise { const snapshot = await callRuntime( page, @@ -126,25 +127,29 @@ async function hostSurfaceStatus( { worktree: `id:${worktreeId}` } ) const surface = snapshot.tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === parentTabId && + (expectedLeafId === undefined || candidate.leafId === expectedLeafId) ) return surface?.type === 'terminal' ? surface.status : null } -// Retry until the host publishes the parked pending handle; stop verification can miss PTY exit under headless load. async function parkHostTerminal( page: Page, environmentId: string, worktreeId: string, parentTabId: string, - options: { expectedPtyId: string } + options: { expectedPtyId: string; original: RuntimeTerminalShow } ): Promise { let lastError = 'terminal.stopExact was never attempted' for (let attempt = 0; attempt < 10; attempt += 1) { + const stopStartedAt = Date.now() const stop = await callRuntime<{ stopped: number stoppedPtyIds: string[] postStopVerified: boolean + postStopFailure?: string }>(page, environmentId, 'terminal.stopExact', { worktree: `id:${worktreeId}`, expectedPtyIds: [options.expectedPtyId], @@ -155,9 +160,57 @@ async function parkHostTerminal( return null }) if (stop) { - expect(stop.postStopVerified).toBe(true) - expect(stop.stopped).toBe(1) - expect(stop.stoppedPtyIds).toEqual([options.expectedPtyId]) + const diagnostic: Record = { + expectedPtyId: options.expectedPtyId, + expectedHostId: options.original.executionHostId ?? null, + expectedLeafId: options.original.leafId, + elapsedMs: Date.now() - stopStartedAt, + stop + } + let parked = stop.postStopVerified === true + if (stop.postStopVerified === false) { + const inventory = await readFreshTerminalInventory(() => + callRuntime(page, environmentId, 'terminal.list', { + requireFreshPtyLiveness: true + }) + ).catch((error: unknown) => ({ error: String(error) })) + const surface = await hostSurfaceStatus( + page, + environmentId, + worktreeId, + parentTabId, + options.original.leafId + ) + .then((status) => ({ parentTabId, leafId: options.original.leafId, status })) + .catch((error: unknown) => ({ parentTabId, error: String(error) })) + Object.assign(diagnostic, { inventory, surface }) + parked = + stop.postStopFailure === 'terminal_liveness_unavailable' && + inventory !== null && + 'terminals' in inventory && + inventory.truncated === false && + inventory.totalCount === inventory.terminals.length && + options.original.executionHostId !== undefined && + inventory.hostScope?.hostIds.includes(options.original.executionHostId) === true && + inventory.hostScope.omittedHostIds.length === 0 && + !inventory.terminals.some((terminal) => terminal.ptyId === options.expectedPtyId) && + options.original.tabId === parentTabId && + 'status' in surface && + surface.status === 'pending-handle' + } + await test + .info() + .attach('host-terminal-park', { + body: JSON.stringify(diagnostic, null, 2), + contentType: 'application/json' + }) + .catch((error: unknown) => { + diagnostic.attachmentError = String(error) + }) + const evidence = JSON.stringify(diagnostic, null, 2) + expect(stop.stopped, evidence).toBe(1) + expect(stop.stoppedPtyIds, evidence).toEqual([options.expectedPtyId]) + expect(parked, evidence).toBe(true) return } // Set mismatch also confirms the no-longer-live PTY is parked when stop verification misses exit. @@ -224,7 +277,8 @@ async function runMaterializationJourney( gate.holdEnd([terminal]) }, originalHandle) await parkHostTerminal(page, environmentId, worktreeId, created.tab.parentTabId, { - expectedPtyId: originalTerminal.terminal.ptyId + expectedPtyId: originalTerminal.terminal.ptyId, + original: originalTerminal.terminal }) // Require an already-parked surface before applying the stale error. await expect diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index 63d5a369151..366ce77fbd3 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -80,8 +80,11 @@ function startHost(): { } { const hostOpcodes: number[] = [] const sendTerminal = vi.fn().mockResolvedValue({ accepted: false }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. const runtime = { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, registerRemoteTerminalViewSubscriber: () => () => {}, resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount: vi.fn().mockReturnValue(true),