diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index 4d5f20158b7..f7fd45e6c89 100644 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -34,18 +34,32 @@ function notifier(revealTerminalSession: ReturnType) { describe('structured TUI launch tab binding', () => { it('proves the published launch tab before returning its revealed renderer binding', async () => { + let explicitStatus: { + state: 'working' | 'done' + prompt: string + receivedAt: number + stateStartedAt: number + paneKey: string + terminalHandle: string + } | null = null const revealTerminalSession = vi.fn( (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => Promise.resolve({ tabId: 'tab-renderer' }) ) - const runtime = new OrcaRuntimeService({ - getSettings: () => ({ - disabledTuiAgents: [], - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {} - }) - } as never) + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } as never, + undefined, + { + getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : []) + } + ) runtime.setNotifier(notifier(revealTerminalSession) as never) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }), @@ -134,6 +148,21 @@ describe('structured TUI launch tab binding', () => { revealTerminalSession.mock.invocationCallOrder[0]! ) + explicitStatus = { + state: 'working', + prompt: '', + receivedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: owner.terminal.paneKey, + terminalHandle: owner.terminal.handle + } + expect(transport.tuiStatus(owner)).toBe('busy') + await expect(transport.waitForTuiIdle(owner, new AbortController().signal)).resolves.toBe(false) + + explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() } + expect(transport.tuiStatus(owner)).toBe('idle') + await expect(transport.waitForTuiIdle(owner, new AbortController().signal)).resolves.toBe(true) + const pty = ( runtime as unknown as { ptysById: Map diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 08d02a052b1..a1210a41ae3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9172,13 +9172,18 @@ export class OrcaRuntimeService { }) return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} }, - waitForTuiIdle: async (owner, signal) => - ( + waitForTuiIdle: async (owner, signal) => { + const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle) + if (explicit) { + return explicit.status === 'idle' + } + return ( await this.waitForTerminal(owner.terminal.handle, { condition: 'tui-idle', signal }) - ).satisfied, + ).satisfied + }, reproveTuiOwner: async ({ record, owner }) => { const persisted = record.lease.ownerProcess if ( @@ -9288,6 +9293,10 @@ export class OrcaRuntimeService { } private structuredTuiStatus(handle: string): 'idle' | 'busy' { + const explicit = this.getFreshExplicitAgentStatusForHandle(handle) + if (explicit) { + return explicit.status === 'idle' ? 'idle' : 'busy' + } const pty = this.getLivePtyForHandle(handle) if (pty) { const text = buildTerminalWaitText( diff --git a/src/main/runtime/structured-tui-exit-proof.test.ts b/src/main/runtime/structured-tui-exit-proof.test.ts index 8dd74d0ef7c..3652aae1880 100644 --- a/src/main/runtime/structured-tui-exit-proof.test.ts +++ b/src/main/runtime/structured-tui-exit-proof.test.ts @@ -44,11 +44,36 @@ describe('structured TUI exit proof', () => { waitForExit: async () => { throw new Error('terminal_handle_stale') }, - probe: async () => proof + probe: async () => proof, + staleHandleProbeAttempts: 1 }) ).rejects.toThrow('terminal_handle_stale') }) + it('retries the persisted process identity when handle retirement wins the exit race', async () => { + const probe = vi + .fn() + .mockResolvedValueOnce({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + }) + .mockResolvedValueOnce({ outcome: 'pid-absent' as const }) + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe, + staleHandleProbeAttempts: 2, + staleHandleProbeIntervalMs: 0 + }) + ).resolves.toBeUndefined() + expect(probe).toHaveBeenNthCalledWith(1, identity) + expect(probe).toHaveBeenNthCalledWith(2, identity) + }) + it('preserves unrelated terminal wait failures', async () => { const probe = vi.fn() diff --git a/src/main/runtime/structured-tui-exit-proof.ts b/src/main/runtime/structured-tui-exit-proof.ts index dcb5e276770..c9c733f4ef2 100644 --- a/src/main/runtime/structured-tui-exit-proof.ts +++ b/src/main/runtime/structured-tui-exit-proof.ts @@ -6,6 +6,30 @@ type ExitProofInput = { identity: AgentSessionProcessIdentity waitForExit: () => Promise probe?: (identity: AgentSessionProcessIdentity) => Promise + staleHandleProbeAttempts?: number + staleHandleProbeIntervalMs?: number +} + +const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50 +const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100 + +function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean { + return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch' +} + +async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise { + const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) + const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS + const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (provesRecordedProcessExited(await probe(input.identity))) { + return + } + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + } + throw staleError } export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise { @@ -15,12 +39,6 @@ export async function waitForStructuredTuiExitProof(input: ExitProofInput): Prom if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') { throw error } - const proof = await ( - input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) - )(input.identity) - if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { - return - } - throw error + await waitForRecordedProcessExit(input, error) } }