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
This commit is contained in:
Neil
2026-10-23 17:42:59 -07:00
committed by GitHub
parent 02d7214c1a
commit d31deaa39f
3 changed files with 137 additions and 8 deletions
@@ -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
}
})
@@ -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<unknown> {
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<unknown> | 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 })
})
})
@@ -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<Set<string> | 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,