From b46fb04487ea75401e691f172ec4c4c8c5b05d66 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:55:42 -0700 Subject: [PATCH] fix: finish admitted terminal prompts after sender disconnects (#34373) * fix: finish admitted terminal prompts after sender disconnects * test: consolidate prompt input contract assertions * Keep first-submit ownership independent of sender observation * Recheck prompt ownership after awaited guards * Cover abandoned prompt admission during restored pane startup * Use typed provider write spy in prompt socket regression --- .../agent-prompt-permission-wait.test.ts | 38 ++++- ...ission-runtime-hook-and-generation.test.ts | 71 +++++++- ...-prompt-submission-runtime-test-fixture.ts | 7 +- .../launched-agent-write-guard.test.ts | 3 + .../runtime/launched-agent-write-guard.ts | 69 +++++--- .../restored-pane-start-cli-handles.test.ts | 11 ++ .../methods/terminal/terminal-send-method.ts | 2 +- .../rpc/terminal-agent-prompt-send.test.ts | 2 +- .../rpc/terminal-agent-prompt-socket.test.ts | 126 ++++++++++++++ .../runtime/runtime-terminal-contracts.ts | 2 + .../terminal/input/agent-prompt-signals.ts | 9 + .../input/terminal-agent-prompt-submit.ts | 3 +- .../terminal/input/terminal-agent-prompt.ts | 160 +++++++++++------- .../terminal/input/terminal-input-dispatch.ts | 3 +- .../output/terminal-output-generation.ts | 14 ++ 15 files changed, 418 insertions(+), 102 deletions(-) create mode 100644 src/main/runtime/rpc/terminal-agent-prompt-socket.test.ts create mode 100644 src/main/runtime/terminal/input/agent-prompt-signals.ts diff --git a/src/main/runtime/agent-prompt-permission-wait.test.ts b/src/main/runtime/agent-prompt-permission-wait.test.ts index 33ff8bcb81a..07af2386222 100644 --- a/src/main/runtime/agent-prompt-permission-wait.test.ts +++ b/src/main/runtime/agent-prompt-permission-wait.test.ts @@ -150,7 +150,7 @@ describe('agent prompt writer: onPermission wait', () => { const sent = runtime.sendTerminalAgentPrompt(handle, 'held prompt', { inputKind: 'driving', onPermission: 'wait', - signal: controller.signal + observationSignal: controller.signal }) const rejected = expect(sent).rejects.toThrow('request_aborted') await vi.advanceTimersByTimeAsync(0) @@ -215,4 +215,40 @@ describe('agent prompt writer: expectedBinding', () => { ).rejects.toThrow('terminal_handle_stale') expect(writes).toEqual([]) }) + it.each(['enrich', 'replace', 'explicit'] as const)('suffix identity %s', async (change) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createRuntime() + const [pane] = (await runtime.listTerminals()).terminals + const binding = runtime.getTerminalPromptRequestBinding(handle) + const register = (incarnationId: string) => + runtime.registerPty('pty-prompt', pane.worktreeId, null, { ...pane, incarnationId }) + let checks = 0 + const sent = runtime.sendTerminalAgentPrompt(handle, 'identity', { + inputKind: 'driving', + acceptQueued: true, + requestId: 'identity', + observationTimeoutMs: 0, + ...(change === 'explicit' ? { expectedBinding: binding } : {}), + beforeWrite: async () => { + if (++checks === 1 && change === 'replace') { + register('old') + } + if (checks === 2) { + register('learned') + } + } + }) + void sent.catch(() => undefined) + await vi.runAllTimersAsync() + expect(writes).toHaveLength(change === 'enrich' ? 2 : 1) + if (change !== 'enrich') { + await expect(sent).rejects.toThrow() + return + } + const receipt = await sent + expect(receipt.prompt?.processIncarnation).toBe('learned') + expect(await runtime.observeTerminalAgentPrompt(handle, receipt.prompt!, 0)).not.toMatchObject({ + observation: 'incarnation_replaced' + }) + }) }) diff --git a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts index ff542351a7c..38c14097179 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts @@ -1,3 +1,5 @@ +import { deliverFollowUp } from '../external-conversations/external-conversation-send' +import { ConversationChain } from '../external-conversations/external-conversation-chain' import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_PROMPT_BRACKETED_PASTE_END, @@ -418,7 +420,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) }) - it('does not queue a replacement generation behind an obsolete submission', async () => { + it.each(['reset', 'exit'] as const)('retires pending %s', async (retirement) => { vi.useFakeTimers() let releaseFirst!: () => void let firstWriteReached!: () => void @@ -441,13 +443,23 @@ describe('agent prompt submission runtime hook and generation cases', () => { await firstGate } }) + const obsolete = expect(first).rejects.toThrow('terminal_handle_stale') await firstWrite - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'reset' }, - 0 - ) + if (retirement === 'exit') { + runtime.onPtyExit('pty-prompt', 0) + } else { + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + 0 + ) + } + await obsolete + if (retirement === 'exit') { + releaseFirst() + return + } const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt', { inputKind: 'driving' }) @@ -456,17 +468,21 @@ describe('agent prompt submission runtime hook and generation cases', () => { expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) releaseFirst() - await expect(first).rejects.toThrow('terminal_handle_stale') }) - it('does not close a partial paste after the PTY generation changes', async () => { + it.each(['generation', 'signal'] as const)('suffix stop %s', async (stop) => { const { runtime, handle, writes } = await createPromptRuntime(() => undefined) let writeChecks = 0 + const controller = new AbortController() const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { inputKind: 'driving', + signal: controller.signal, beforeWrite: () => { writeChecks += 1 + if (writeChecks === 2 && stop === 'signal') { + return controller.abort() + } if (writeChecks === 2) { runtime.synchronizePtyOutputSequenceFromProvider( 'pty-prompt', @@ -477,7 +493,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { } }) - await expect(submission).rejects.toThrow('terminal_handle_stale') + await expect(submission).rejects.toThrow( + stop === 'signal' ? 'request_aborted' : 'terminal_handle_stale' + ) expect(writes).toHaveLength(1) expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END) }) @@ -500,6 +518,41 @@ describe('agent prompt submission runtime hook and generation cases', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(0) }) + it.each(['agent', 'reject'] as const)('follow-up cancel before late %s', async (late) => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + const probe = Promise.withResolvers<'agent'>(), + reached = Promise.withResolvers() + const unsubscribe = vi.fn() + vi.spyOn(runtime, 'subscribeToTerminalData').mockReturnValue(unsubscribe) + vi.mocked(runtime.readLaunchedAgentForeground).mockImplementation(() => { + reached.resolve() + return probe.promise + }) + const chain = new ConversationChain(new AbortController().signal) + const delivered = chain.enqueue('prompt', (signal) => + deliverFollowUp(runtime, { + handle, + agent: 'claude', + expectedBinding: runtime.getTerminalPromptRequestBinding(handle), + text: 'follow-up', + requestId: 'follow-up', + signal + }) + ) + await reached.promise + void chain.cancel('prompt', 'conversation_cancelled') + await expect(delivered).resolves.toMatchObject({ outcome: 'refused' }) + expect(unsubscribe).toHaveBeenCalledTimes(1) + if (late === 'agent') { + probe.resolve('agent') + } else { + probe.reject(new Error('late failure')) + } + await Promise.resolve() + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(writes).toEqual([]) + }) + it('does not send another Enter after cancellation during verification', async () => { vi.useFakeTimers() const controller = new AbortController() diff --git a/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts b/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts index 4f8ff03b61d..bc6c691e94c 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts @@ -8,7 +8,8 @@ export const AGENT_PROMPT_TEST_WORKTREE_ID = 'repo-1::/tmp/worktree-a' export async function createAgentPromptSubmissionRuntime( onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void, - launchAgent: TuiAgent = 'aider' + launchAgent: TuiAgent = 'aider', + write?: (runtime: OrcaRuntimeService, ptyId: string, data: string) => boolean ): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { const runtime = new OrcaRuntimeService(makeStore() as never) const writes: string[] = [] @@ -17,10 +18,10 @@ export async function createAgentPromptSubmissionRuntime( write: (_ptyId, data) => { writes.push(data) onWrite(runtime, data, writes.length) - return true + return write?.(runtime, _ptyId, data) ?? true }, kill: () => true, - getForegroundProcess: async () => null + getForegroundProcess: async () => (write ? launchAgent : null) }) const terminal = await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { launchAgent diff --git a/src/main/runtime/launched-agent-write-guard.test.ts b/src/main/runtime/launched-agent-write-guard.test.ts index 6bd6846f685..d1fb98f9154 100644 --- a/src/main/runtime/launched-agent-write-guard.test.ts +++ b/src/main/runtime/launched-agent-write-guard.test.ts @@ -83,7 +83,10 @@ describe('the check before each write of a launch prompt', () => { const cleared = guardHarness(['agent']) await cleared.guard.beforeWrite('pty-1') + const scheduled = Promise.resolve().then(() => cleared.guard.beforeWrite('pty-1')) cleared.guard.dispose() + await expect(scheduled).rejects.toThrow('request_aborted') + expect(cleared.runtime.subscribeToTerminalData).toHaveBeenCalledTimes(1) expect(cleared.unsubscribe).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/runtime/launched-agent-write-guard.ts b/src/main/runtime/launched-agent-write-guard.ts index 7c65dec3e04..a36523996c2 100644 --- a/src/main/runtime/launched-agent-write-guard.ts +++ b/src/main/runtime/launched-agent-write-guard.ts @@ -29,19 +29,33 @@ export function createLaunchedAgentWriteGuard( agent: TuiAgent, { unprovableHost = 'refuse' }: { unprovableHost?: UnprovableHostPolicy } = {} ): LaunchedAgentWriteGuard { - let cleared: { ptyId: string; shellMayHaveReturned: boolean; unsubscribe: () => void } | null = - null + let disposed = false + let current: { + ptyId: string + proven: boolean + shellMayHaveReturned: boolean + unsubscribe: () => void + } | null = null + const clearWatch = (): void => { + const previous = current + current = null + previous?.unsubscribe() + } const dispose = (): void => { - cleared?.unsubscribe() - cleared = null + disposed = true + clearWatch() } const beforeWrite = async (ptyId: string): Promise => { - if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) { + if (disposed) { + throw new Error('request_aborted') + } + if (current?.ptyId === ptyId && current.proven && !current.shellMayHaveReturned) { return } - dispose() + clearWatch() let carry = '' - const watch = { ptyId, shellMayHaveReturned: false, unsubscribe: (): void => {} } + const watch = { ptyId, proven: false, shellMayHaveReturned: false, unsubscribe: (): void => {} } + current = watch // Subscribe before probing so shell return during the read cannot be missed. watch.unsubscribe = runtime.subscribeToTerminalData(ptyId, (data) => { const window = carry + data @@ -50,27 +64,34 @@ export function createLaunchedAgentWriteGuard( watch.shellMayHaveReturned = true } }) - let foreground: LaunchedAgentForeground try { - foreground = await runtime.readLaunchedAgentForeground(ptyId, agent) + const foreground: LaunchedAgentForeground = await runtime.readLaunchedAgentForeground( + ptyId, + agent + ) + if (disposed || current !== watch) { + throw new Error('request_aborted') + } + if (foreground === 'agent') { + watch.proven = true + return + } + clearWatch() + if ( + foreground === 'shell' || + unprovableHost === 'refuse' || + runtime.launchedAgentHostProvesAgent?.(ptyId) !== false || + (unprovableHost === 'write-if-shell-provable' && + runtime.launchedAgentHostProvesShellAlone?.(ptyId) !== true) + ) { + throw new Error('agent_not_in_foreground') + } } catch (error) { - watch.unsubscribe() + if (current === watch) { + clearWatch() + } throw error } - if (foreground === 'agent') { - cleared = watch - return - } - watch.unsubscribe() - if ( - foreground === 'shell' || - unprovableHost === 'refuse' || - runtime.launchedAgentHostProvesAgent?.(ptyId) !== false || - (unprovableHost === 'write-if-shell-provable' && - runtime.launchedAgentHostProvesShellAlone?.(ptyId) !== true) - ) { - throw new Error('agent_not_in_foreground') - } } return { beforeWrite, dispose } } diff --git a/src/main/runtime/restored-pane-start-cli-handles.test.ts b/src/main/runtime/restored-pane-start-cli-handles.test.ts index 1c5cce0ad3b..61a5c832473 100644 --- a/src/main/runtime/restored-pane-start-cli-handles.test.ts +++ b/src/main/runtime/restored-pane-start-cli-handles.test.ts @@ -85,6 +85,16 @@ const DRIVING = { inputKind: 'driving' } as const describe('a CLI trigger on a saved pane with no terminal (layout-owner build)', () => { it('a send starts the pane once and delivers to it, and the handle keeps naming it', async () => { const { runtime, handle, spawn, started, write } = await restoredPane() + const observer = new AbortController() + const abandoned = runtime.sendTerminalAgentPrompt(handle, 'abandoned', { + ...DRIVING, + observationSignal: observer.signal + }) + const refused = expect(abandoned).rejects.toThrow('request_aborted') + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + observer.abort() + await refused + expect(write).not.toHaveBeenCalled() const send = runtime.sendTerminal(handle, { text: 'echo hi', enter: true }, DRIVING) await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) @@ -96,6 +106,7 @@ describe('a CLI trigger on a saved pane with no terminal (layout-owner build)', runtime.sendTerminal(handle, { text: 'echo again', enter: true }, DRIVING) ).resolves.toMatchObject({ handle, accepted: true }) expect(write).toHaveBeenCalledWith('pty-c', 'echo again', 'driving') + expect(write).toHaveBeenCalledTimes(4) expect(spawn).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index b215984f07b..d598372f665 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -197,7 +197,7 @@ export const TERMINAL_SEND_METHODS = [ ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { inputKind: 'driving', beforeWrite, - signal, + observationSignal: signal, ...(orchestrationMutation ? { acceptQueued: true, diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index ba4cd3cb4f6..8a0db1b4fb4 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -48,7 +48,7 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', { inputKind: 'driving', beforeWrite: undefined, - signal: undefined + observationSignal: undefined }) expect(sendTerminal).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/terminal-agent-prompt-socket.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-socket.test.ts new file mode 100644 index 00000000000..f42c810f6cf --- /dev/null +++ b/src/main/runtime/rpc/terminal-agent-prompt-socket.test.ts @@ -0,0 +1,126 @@ +import './unused-default-rpc-methods.test-fixture' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { expect, it, vi } from 'vitest' +import { createAgentPromptSubmissionRuntime } from '../agent-prompt-submission-runtime-test-fixture' +import { UnixSocketTransport } from './unix-socket-transport' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import { TERMINAL_METHODS } from './methods/terminal' +import { STATUS_METHODS } from './methods/status' +import { OrchestrationDb } from '../orchestration/db' +import { writePtyFromRuntimeController } from '../../ipc/pty/runtime/operations' +import { getLocalPtyProvider } from '../../ipc/pty/provider/registry' + +vi.mock('../../git/worktree', () => { + const read = async () => [ + { path: '/tmp/worktree-a', head: 'abc', branch: 'prompt', isBare: false, isMainWorktree: false } + ] + return { listWorktrees: read, listWorktreesStrict: read } +}) + +it.each(['before', 'after'] as const)( + 'real CLI close %s paste finishes admitted input and drops a cancelled follower', + async (boundary) => { + const latch = () => Promise.withResolvers() + const [pasted, release, closed, received] = [latch(), latch(), latch(), latch()] + const root = mkdtempSync(join(tmpdir(), 'orca-prompt-socket-')) + const kind = process.platform === 'win32' ? 'named-pipe' : 'unix' + const endpoint = + kind === 'named-pipe' ? `\\\\.\\pipe\\${root.split(/[/\\]/).at(-1)}` : join(root, 'rpc') + const transport = new UnixSocketTransport({ endpoint, kind }) + const providerWrite = vi.spyOn(getLocalPtyProvider(), 'write').mockReturnValue(true) + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + () => pasted.resolve(), + 'claude', + (host, id, data) => writePtyFromRuntimeController({ runtime: host }, id, data, 'driving') + ) + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const methods = [...STATUS_METHODS, ...TERMINAL_METHODS] + const dispatcher = new RpcDispatcher({ runtime, methods }) + Object.defineProperty(runtime, 'createAgentPromptRenderGate', { + value: () => ({ arm: () => {}, wait: () => release.promise, dispose: () => {} }) + }) + const done: Promise[] = [] + transport.onMessage((raw, reply, context) => { + const request: RpcRequest = JSON.parse(raw) + const prompt = request.method === 'terminal.send' + if (prompt) { + received.resolve() + context!.signal.addEventListener('abort', () => closed.resolve(), { once: true }) + } + const admission = prompt && boundary === 'before' ? release.promise : Promise.resolve() + done.push( + admission.then(async () => + reply(JSON.stringify(await dispatcher.dispatch(request, context))) + ) + ) + }) + await transport.start() + const discovery = { + runtimeId: runtime.getRuntimeId(), + pid: process.pid, + startedAt: 1, + authToken: 'fixture-token', + transports: [{ kind, endpoint }] + } + writeFileSync(join(root, 'orca-runtime.json'), JSON.stringify(discovery)) + const preload = join(root, 'exit-on-input.ts') + writeFileSync(preload, "process.stdin.once('data', () => process.exit(0))") + const program = createRequire(resolve('package.json')).resolve('bun/bin/bun.exe') + const env = { + ...Object.fromEntries( + 'HOME USERPROFILE APPDATA LOCALAPPDATA XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME XDG_STATE_HOME ORCA_E2E_HOME_DIR ORCA_E2E_USER_DATA_DIR ORCA_USER_DATA_PATH TEMP TMP TMPDIR CODEX_HOME CLAUDE_CONFIG_DIR' + .split(' ') + .map((key) => [key, root]) + ), + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot + } + const args = ['terminal', 'send', '--terminal', handle, '--text', 'first', '--enter', '--json'] + const command = ['--preload', preload, resolve('src/cli/index.ts'), ...args] + const child = spawn(program, command, { cwd: root, env, stdio: 'pipe' }) + const exited = once(child, 'close') + child.stderr.resume() + try { + await Promise.race([ + received.promise, + exited.then(() => expect.fail('CLI exited before sending')) + ]) + await (boundary === 'after' ? pasted.promise : received.promise) + child.stdin.end('exit') // The test-owned child exits itself; no process-tree termination. + await exited + await closed.promise + const secondObserver = new AbortController() + const second = runtime.sendTerminalAgentPrompt(handle, 'second', { + inputKind: 'driving', + observationSignal: secondObserver.signal + }) + void second.catch(() => undefined) + secondObserver.abort() + release.resolve() + await Promise.all(done) + await expect(second).rejects.toThrow('request_aborted') + expect(writes).toEqual(boundary === 'after' ? [expect.stringContaining('first'), '\r'] : []) + } finally { + release.resolve() + if (child.exitCode === null) { + child.stdin.end('exit') + } + await exited + await transport.stop() + await Promise.allSettled(done) + db.close() + providerWrite.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }, + 20_000 +) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 65b5f63c0cd..f6ecad46652 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -262,6 +262,8 @@ export type RuntimeProviderSnapshotReadOptions = { export type RuntimeAgentPromptWriteOptions = Omit & { /** `launch` for the prompt an agent starts with; `driving` for any prompt sent to a running one. */ inputKind: Exclude + /** Caller observation may end without cancelling a paste already accepted by the host. */ + observationSignal?: AbortSignal /** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */ promptForSchedule?: string /** Caller observed composer input acceptance; schedule Enter using desktop draft-paste timing instead of render settlement. */ diff --git a/src/main/runtime/terminal/input/agent-prompt-signals.ts b/src/main/runtime/terminal/input/agent-prompt-signals.ts new file mode 100644 index 00000000000..a5ea20fc127 --- /dev/null +++ b/src/main/runtime/terminal/input/agent-prompt-signals.ts @@ -0,0 +1,9 @@ +import type { RuntimeAgentPromptWriteOptions } from '../../runtime-terminal-contracts' + +export function agentPromptObservationSignal( + options: RuntimeAgentPromptWriteOptions +): AbortSignal | undefined { + return options.signal && options.observationSignal + ? AbortSignal.any([options.signal, options.observationSignal]) + : (options.signal ?? options.observationSignal) +} diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt-submit.ts b/src/main/runtime/terminal/input/terminal-agent-prompt-submit.ts index d4ac1418b8b..ccef546e648 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt-submit.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt-submit.ts @@ -1,3 +1,4 @@ +import { agentPromptObservationSignal } from './agent-prompt-signals' import type { RuntimeAgentPromptWriteOptions } from '../../runtime-terminal-contracts' import type { RuntimeTerminalSend } from '../../../../shared/runtime-types' import type { AgentTransitionDetector } from '../../../agent-hooks/agent-transition-detector' @@ -74,7 +75,7 @@ export abstract class OrcaRuntimeWithSubmitTerminalAgentPrompt extends OrcaRunti } : {}), subscribeExit: (exited) => this.subscribeToPtyExit(ptyId, exited), - signal: options.signal + signal: agentPromptObservationSignal(options) }) } } diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt.ts b/src/main/runtime/terminal/input/terminal-agent-prompt.ts index c01359d6a64..fa4addb4e7b 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt.ts @@ -1,3 +1,4 @@ +import { agentPromptObservationSignal } from './agent-prompt-signals' import type { AgentPromptExpectedBinding, RuntimeAgentPromptWriteOptions @@ -71,7 +72,8 @@ export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntim pastePayload: string, options: RuntimeAgentPromptWriteOptions ): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> { - assertAgentPromptRequestActive(options.signal) + const observationSignal = agentPromptObservationSignal(options) + assertAgentPromptRequestActive(observationSignal) this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) if (options.onPermission === 'wait') { @@ -94,13 +96,41 @@ export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntim const preSubmitBaseline = submitWithPaste ? this.getAgentPromptActivity(handle, ptyId, waitTextCache) : undefined - try { - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - await options.beforeWrite?.(ptyId) - assertAgentPromptRequestActive(options.signal) + const incarnationId = pty?.incarnationId + const lifecycleSignal = this.getPtyLifecycleSignal(ptyId) + const deliverySignal = options.signal + ? AbortSignal.any([options.signal, lifecycleSignal]) + : lifecycleSignal + const admissionSignal = observationSignal + ? AbortSignal.any([observationSignal, deliverySignal]) + : deliverySignal + const assertWritable = (signal: AbortSignal): void => { + assertAgentPromptRequestActive(signal) this.assertAgentPromptGeneration(ptyId, generation) + this.assertLiveTerminalHandleTargetsPty(handle, ptyId) + if (incarnationId != null && this.ptysById.get(ptyId)?.incarnationId !== incarnationId) { + throw new Error('terminal_handle_stale') + } this.assertAgentPromptExpectedBinding(handle, options.expectedBinding) + if (this.isPtyStopRequested(ptyId)) { + throw new Error('terminal_not_writable') + } + } + const beforeWrite = async (signal: AbortSignal): Promise => { + assertWritable(signal) + await waitForAgentPromptPromise( + Promise.resolve().then(() => { + assertWritable(signal) + return options.beforeWrite?.(ptyId) + }), + signal + ) + assertWritable(signal) + } + let baseline: AgentPromptActivity + try { + await beforeWrite(admissionSignal) + assertWritable(admissionSignal) if (options.onPermission === 'wait') { this.throwIfAgentPromptPermissionReturned( permissionBaseline, @@ -120,74 +150,82 @@ export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntim if (submitWithPaste) { options.onSubmitWritten?.() } - } catch (error) { - renderGate?.dispose() - throw error - } - - if (submitWithPaste) { - // The Enter was part of the paste frame; waiting here would only delay receipt settlement. - renderGate?.dispose() - } else if (renderGate) { - try { - await waitForAgentPromptPromise(renderGate.wait(), options.signal) - } finally { - renderGate.dispose() - } - } else if (options.composerReady) { - await waitForAgentPromptDelay( - AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS + pasteIngestMs, - options.signal - ) - } else { - const agent = this.getPtyAgent(ptyId) - const submitDelayMs = options.promptForSchedule - ? resolveAgentPromptSubmitDelayForAgent(writeHostPlatform, options.promptForSchedule, agent) - : getAgentPromptSubmitDelayMs(writeHostPlatform, pasteByteLength) - await waitForAgentPromptDelay(submitDelayMs, options.signal) - } - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - if (!submitWithPaste) { - try { - await options.beforeWrite?.(ptyId) - } catch (error) { - if (options.suffixFailureError) { - throw new Error(options.suffixFailureError) + if (!submitWithPaste) { + // Once paste is accepted, only host cancellation can suppress its first Enter. + if (renderGate) { + await waitForAgentPromptPromise(renderGate.wait(), deliverySignal) + } else if (options.composerReady) { + await waitForAgentPromptDelay( + AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS + pasteIngestMs, + deliverySignal + ) + } else { + const agent = this.getPtyAgent(ptyId) + const submitDelayMs = options.promptForSchedule + ? resolveAgentPromptSubmitDelayForAgent( + writeHostPlatform, + options.promptForSchedule, + agent + ) + : getAgentPromptSubmitDelayMs(writeHostPlatform, pasteByteLength) + await waitForAgentPromptDelay(submitDelayMs, deliverySignal) + } + try { + await beforeWrite(deliverySignal) + } catch (error) { + if (options.suffixFailureError) { + throw new Error(options.suffixFailureError) + } + throw error } - throw error } - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - } - const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) - this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) - if (!submitWithPaste) { - if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { + assertWritable(deliverySignal) + baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) + this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) + if ( + !submitWithPaste && + !this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind) + ) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } - options.onSubmitWritten?.() + if (!submitWithPaste) { + options.onSubmitWritten?.() + } + } catch (error) { + this.assertAgentPromptGeneration(ptyId, generation) + throw error + } finally { + renderGate?.dispose() } const submits = submitWithPaste ? 1 : 1 + - (await this.resubmitHeldAgentPrompt(ptyId, generation, options, { - baseline, - readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), - prompt: options.promptForSchedule, - heldBudgetMs: getAgentPromptHeldSubmitBudgetMs( - writeHostPlatform, - this.getPtyAgent(ptyId), - pastePayload - ) - })) + (await this.resubmitHeldAgentPrompt( + ptyId, + generation, + { + ...options, + signal: admissionSignal, + beforeWrite: () => beforeWrite(admissionSignal) + }, + { + baseline, + readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), + prompt: options.promptForSchedule, + heldBudgetMs: getAgentPromptHeldSubmitBudgetMs( + writeHostPlatform, + this.getPtyAgent(ptyId), + pastePayload + ) + } + )) const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)) if (!options.acceptQueued || !options.requestId) { await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), timeoutMs: effectTimeoutMs, - signal: options.signal + signal: observationSignal }) return { submits } } @@ -242,7 +280,7 @@ export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntim evidence ), allowOutputEvidence: false, - signal: options.signal, + signal: observationSignal, timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs }) this.forgetAgentPromptRequest(ptyId, generation, options.requestId) diff --git a/src/main/runtime/terminal/input/terminal-input-dispatch.ts b/src/main/runtime/terminal/input/terminal-input-dispatch.ts index d4cf7bb2a50..1c19ee41a39 100644 --- a/src/main/runtime/terminal/input/terminal-input-dispatch.ts +++ b/src/main/runtime/terminal/input/terminal-input-dispatch.ts @@ -1,3 +1,4 @@ +import { agentPromptObservationSignal } from './agent-prompt-signals' import type { RuntimeTerminalWriteOptions } from '../../runtime-terminal-writer' import type { WriteSettlement } from '../../../../shared/pty-write-settlement' import type { RuntimeLeafRecord } from '../../runtime-terminal-state-records' @@ -226,7 +227,7 @@ export abstract class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntim } // Switch point: a saved pane main holds with no terminal yet starts first (once, shared). const starting = ORCA_LAYOUT_OWNER_IS_MAIN - ? this.startRestoredHandlePane(handle, { signal: options.signal }) + ? this.startRestoredHandlePane(handle, { signal: agentPromptObservationSignal(options) }) : null if (starting) { const target = await starting diff --git a/src/main/runtime/terminal/output/terminal-output-generation.ts b/src/main/runtime/terminal/output/terminal-output-generation.ts index d20b02353cd..10265e957b6 100644 --- a/src/main/runtime/terminal/output/terminal-output-generation.ts +++ b/src/main/runtime/terminal/output/terminal-output-generation.ts @@ -6,6 +6,17 @@ import { extractLastOscTitle } from '../../../../shared/osc-title-extraction' import type { RuntimeTerminalDataMeta } from '../../runtime-terminal-stream-consumers' export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector { + private readonly ptyLifecycleCancellation = new Map() + + protected getPtyLifecycleSignal(ptyId: string): AbortSignal { + let controller = this.ptyLifecycleCancellation.get(ptyId) + if (!controller) { + controller = new AbortController() + this.ptyLifecycleCancellation.set(ptyId, controller) + } + return controller.signal + } + protected recordAgentPromptLifecycleState(ptyId: string, status: AgentStatus | null): void { if (status === 'permission') { this.recordAgentPromptPermissionObservation(ptyId) @@ -87,6 +98,9 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc protected advancePtyLifecycleGeneration(ptyId: string): void { this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) + const cancellation = this.ptyLifecycleCancellation.get(ptyId) + this.ptyLifecycleCancellation.delete(ptyId) + cancellation?.abort() this.clearAgentPromptCorrelationForPty(ptyId) // A stop intent belongs to one process incarnation; never let it label a // replacement process when the provider reports a generation reset.