mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user