From d31deaa39f10d4aaa7e2fc2833c9eefe8eff9515 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:42:59 -0700 Subject: [PATCH] fix(terminal): verify an exact stop through an overlapping liveness inventory (#32948) * fix(terminal): verify an exact stop through an overlapping liveness inventory terminal.stopExact re-read the PTY inventory once after the stop. When a newer inventory started meanwhile (the exit's own session republish triggers one), the older read was voided as stale and the confirmed stop came back as terminal_liveness_unavailable, so hibernating a remote pane on a busy host rolled back. Retry the post-stop read within one list budget. * test(terminal): keep the post-stop liveness failure fixture unavailable across retries --- ...erminal-sleep-and-teardown-part-02.spec.ts | 12 +- .../stop/exact-terminal-stop-liveness.test.ts | 106 ++++++++++++++++++ .../stop/worktree-terminal-stop-targets.ts | 27 ++++- 3 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts diff --git a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts index 6d8059a212e..be85255343a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts @@ -568,10 +568,7 @@ describe('OrcaRuntimeService', () => { it('reports recoverable post-stop liveness failure after exact terminal stop', async () => { const runtime = new OrcaRuntimeService(store) const stopped: string[] = [] - const processLists = [ - [{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], - new Error('daemon unavailable') - ] + const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }]] runtime.setPtyController({ write: () => true, kill: () => false, @@ -581,12 +578,13 @@ describe('OrcaRuntimeService', () => { return true }, getForegroundProcess: async () => null, + // Why: the daemon stays unavailable through every bounded post-stop retry. listProcesses: async () => { const next = processLists.shift() - if (next instanceof Error) { - throw next + if (!next) { + throw new Error('daemon unavailable') } - return next ?? [] + return next } }) diff --git a/src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts b/src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts new file mode 100644 index 00000000000..551d5908b1e --- /dev/null +++ b/src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../orca-runtime' + +const WORKTREE = 'repo::/tmp/exact-stop' +const PTY = `${WORKTREE}@@terminal` +const TAB = '10000000-0000-4000-8000-000000000001' +const LEAF = '10000000-0000-4000-8000-000000000002' +const INCARNATION = '10000000-0000-4000-8000-000000000003' + +class ExactStopRuntime extends OrcaRuntimeService { + override resolveWorktreeSelector(selector: string) { + return super.resolveWorktreeSelector(selector) + } + + override getResolvedWorktreeMap() { + return super.getResolvedWorktreeMap() + } + + async overlappingInventory(): Promise { + return this.refreshPtyWorktreeRecordsWithControllerInventory( + [...(await this.getResolvedWorktreeMap()).values()], + null, + undefined, + null + ) + } +} + +function folderWorktree() { + const git = { + path: '/tmp/exact-stop', + head: 'abc', + branch: 'main', + isBare: false, + isMainWorktree: false + } + return { + ...git, + git, + id: WORKTREE, + repoId: 'repo', + displayName: 'exact-stop', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + } +} + +describe('terminal.stopExact post-stop verification', () => { + it('verifies a confirmed stop even when an overlapping inventory voids its first check', async () => { + const runtime = new ExactStopRuntime() + const worktree = folderWorktree() + vi.spyOn(runtime, 'resolveWorktreeSelector').mockResolvedValue(worktree) + vi.spyOn(runtime, 'getResolvedWorktreeMap').mockResolvedValue(new Map([[WORKTREE, worktree]])) + let stopped = false + let overlapped: Promise | null = null + runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async () => { + runtime.onPtyExit(PTY, 0, INCARNATION, { providerExitObserved: true }) + stopped = true + return true + }, + getForegroundProcess: async () => null, + hasPty: () => !stopped, + listProcesses: async () => { + if (!stopped) { + return [{ id: PTY, incarnationId: INCARNATION, cwd: worktree.path, title: 'terminal' }] + } + // Why: the exit's own session republish starts a newer inventory mid-check, as on a loaded host. + overlapped ??= runtime.overlappingInventory() + return [] + } + }) + runtime.registerPty(PTY, WORKTREE, null, { + tabId: TAB, + leafId: LEAF, + incarnationId: INCARNATION + }) + runtime.syncWindowGraph(1, { + tabs: [ + { tabId: TAB, worktreeId: WORKTREE, title: 'terminal', activeLeafId: LEAF, layout: null } + ], + leaves: [{ tabId: TAB, worktreeId: WORKTREE, leafId: LEAF, paneRuntimeId: 1, ptyId: PTY }] + }) + + const result = await runtime.stopExactTerminalsForWorktree(`id:${WORKTREE}`, [PTY], { + keepHistory: true, + targetOnly: true + }) + await overlapped + + expect(overlapped).not.toBeNull() + expect(result).toMatchObject({ stoppedPtyIds: [PTY], postStopVerified: true }) + }) +}) diff --git a/src/main/runtime/terminal/stop/worktree-terminal-stop-targets.ts b/src/main/runtime/terminal/stop/worktree-terminal-stop-targets.ts index 0bd879c230f..b033697f3e4 100644 --- a/src/main/runtime/terminal/stop/worktree-terminal-stop-targets.ts +++ b/src/main/runtime/terminal/stop/worktree-terminal-stop-targets.ts @@ -1,3 +1,4 @@ +import { setTimeout as delay } from 'node:timers/promises' import { OrcaRuntimeWithSleepResolvedWorktreeTerminals } from './worktree-terminal-sleep' import { setsEqual } from '../../runtime-worktree-binding-index' import type { RuntimeLeafRecord } from '../../runtime-terminal-state-records' @@ -5,6 +6,13 @@ import { worktreeIdsEqual } from '../../../../shared/worktree/id' import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from '../../worktree-pty-host-fence' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { mainHoldsTerminal } from '../surface/main-pane-verdict' +import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' +import { PTY_CONTROLLER_LIST_TIMEOUT_MS } from '../../orca-runtime-postlude' + +// Why: a newer overlapping inventory (e.g. the session republish the exit itself triggers) voids +// this one; retry within one list budget instead of reporting a confirmed stop as unverifiable. +const POST_STOP_LIVENESS_RETRY_MS = PTY_CONTROLLER_LIST_TIMEOUT_MS +const POST_STOP_LIVENESS_RETRY_DELAY_MS = 100 export abstract class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWithSleepResolvedWorktreeTerminals { protected abstract issueHandle(leaf: RuntimeLeafRecord): string @@ -71,7 +79,7 @@ export abstract class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaR } stoppedPtyIds.push(ptyId) } - const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + const postStopLiveness = await this.refreshPostStopPtyLiveness(resolvedWorktrees) if (!postStopLiveness) { return { stopped: stoppedPtyIds.length, @@ -104,6 +112,23 @@ export abstract class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaR } } + private async refreshPostStopPtyLiveness( + resolvedWorktrees: ResolvedWorktree[] + ): Promise | null> { + const deadline = Date.now() + POST_STOP_LIVENESS_RETRY_MS + for (;;) { + const liveness = await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + null, + deadline + ) + if (liveness || Date.now() + POST_STOP_LIVENESS_RETRY_DELAY_MS >= deadline) { + return liveness + } + await delay(POST_STOP_LIVENESS_RETRY_DELAY_MS) + } + } + /** With `hostFence`, only that host's copy: a same-id twin's PTYs are another host's. */ protected getLivePtyIdsForWorktree( worktreeId: string,