fix(agent-status): retire any restored agent row whose terminal died while Orca was closed (STA-10194) (#28475)

* wip

* refactor(agent-status): tighten the restored-status sweep after review

- reword preserveResumeIdentity to the real rule: the pane survives to resume into
- one isLocalHookConnectionId predicate for the sweep, removed-worktree cleanup, and runtime PTY sources
- the startup wrapper is file-private with a distinct name; probe catch simplified
- stale 'liveness reap' wording in the Claude roster comment and test
- tests: the Claude roster case is asserted through a later turn settling, and a hydrated
  resume remnant is pinned as never a candidate
This commit is contained in:
Jinwoo Hong
2026-10-13 23:48:52 -04:00
committed by GitHub
parent 03eee256f1
commit b9bd8dbe4a
14 changed files with 583 additions and 845 deletions
+1 -1
View File
@@ -589,7 +589,7 @@
"src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts": 3465,
"src/main/agent-hooks/remote-hook-service-installers.test.ts": 453,
"src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts": 430,
"src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 821,
"src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts": 821,
"src/main/agent-hooks/server-agent-presence.test.ts": 732,
"src/main/agent-hooks/server-ai-vault-liveness.test.ts": 669,
"src/main/agent-hooks/server-amp-normalization.test.ts": 693,
@@ -0,0 +1,487 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSubagentSnapshot } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { toAppSshPtyId } from '../../shared/ssh-pty-id'
import { AgentHookServer } from './server'
import { buildBody, postHookEvent } from './server.test-fixtures'
import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
resolveAgentWorkspaceExecutionHostId,
retireRestoredStatusesWithDeadTerminals
} from './restored-status-dead-terminal-sweep'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
const OTHER_PANE = makePaneKey('tab-2', LEAF)
const PTY = 'wt-1__pty-1'
const WORKING_CHILD: AgentSubagentSnapshot = {
id: 'areview-loop-c237a4c577493352',
state: 'working',
startedAt: 1_000
}
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-restored-status-'))
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(dir, { recursive: true, force: true })
})
type PersistedRow = {
paneKey?: string
agentType?: string
state?: 'working' | 'waiting' | 'blocked' | 'done'
connectionId?: string | null
subagents?: AgentSubagentSnapshot[]
providerSessionOnly?: boolean
}
/** Start a server over the last-status.json an Orca that closed mid-turn leaves behind. */
async function restartWith(rows: PersistedRow[] = [{}]): Promise<AgentHookServer> {
const now = Date.now()
const entries = Object.fromEntries(
rows.map((row) => {
const paneKey = row.paneKey ?? PANE
return [
paneKey,
{
paneKey,
tabId: paneKey.split(':')[0],
worktreeId: 'wt-1',
connectionId: row.connectionId ?? null,
providerSession: { key: 'session_id', id: `resume-${paneKey}` },
...(row.providerSessionOnly
? { providerSessionOnly: true, retainedForLiveness: true }
: {}),
payload: {
state: row.state ?? 'working',
prompt: 'review the PR',
agentType: row.agentType ?? 'claude',
...(row.subagents ? { subagents: row.subagents } : {})
},
receivedAt: now,
stateStartedAt: now
}
]
})
)
mkdirSync(join(dir, 'agent-hooks'), { recursive: true })
writeFileSync(
join(dir, 'agent-hooks', 'last-status.json'),
JSON.stringify({ version: 2, entries, authorityCommitments: {} })
)
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
return server
}
function sweepWith(
server: AgentHookServer,
overrides: {
probePtyLiveness?: (ptyId: string) => boolean | null | Promise<boolean | null>
executionHostId?: string | null
boundPtyIdByPaneKey?: Record<string, string>
persistedPtyIdByPaneKey?: Record<string, string>
} = {}
): Promise<number> {
return retireRestoredStatusesWithDeadTerminals({
server,
probePtyLiveness: async (ptyId) =>
overrides.probePtyLiveness ? await overrides.probePtyLiveness(ptyId) : false,
isLocalExecutionHost: () =>
isLocalExecutionHost(
overrides.executionHostId === undefined ? 'local' : overrides.executionHostId
),
getBoundPtyIdForPaneKey: (paneKey) => overrides.boundPtyIdByPaneKey?.[paneKey],
getPersistedPtyIdForPaneKey: (paneKey) =>
(overrides.persistedPtyIdByPaneKey ?? { [PANE]: PTY })[paneKey]
})
}
function paneState(server: AgentHookServer, paneKey = PANE): string {
const entry = server.getStatusSnapshotForPane(paneKey)[0]
return entry && !entry.providerSessionOnly ? entry.state : 'missing'
}
function deferredProbe(): {
probePtyLiveness: ReturnType<typeof vi.fn<(ptyId: string) => Promise<boolean | null>>>
resolve: (live: boolean | null) => void
} {
let resolve!: (live: boolean | null) => void
const probePtyLiveness = vi.fn(
() =>
new Promise<boolean | null>((settle) => {
resolve = settle
})
)
return { probePtyLiveness, resolve: (live) => resolve(live) }
}
describe('restored status dead-terminal sweep', () => {
it.each(['codex', 'omp', 'claude'])(
'retires a restored working %s row whose terminal is gone, keeping it resumable',
async (agentType) => {
const server = await restartWith([{ agentType }])
try {
expect(server.getStatusSnapshotForPane(PANE)[0]?.restoredUnconfirmed).toBe(true)
expect(await sweepWith(server)).toBe(1)
expect(paneState(server)).toBe('missing')
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
providerSessionOnly: true,
providerSession: { id: `resume-${PANE}` }
})
} finally {
server.stop()
}
}
)
it('retires a WSL pane, whose PTY lives in the local provider', async () => {
const server = await restartWith([{ connectionId: 'wsl:Ubuntu' }])
try {
expect(await sweepWith(server)).toBe(1)
expect(paneState(server)).toBe('missing')
} finally {
server.stop()
}
})
it('retires a Claude pane held open by restored subagents so a later turn there settles', async () => {
const server = await restartWith([{ subagents: [WORKING_CHILD] }])
try {
expect(await sweepWith(server)).toBe(1)
expect(paneState(server)).toBe('missing')
for (const hook_event_name of ['UserPromptSubmit', 'Stop']) {
const response = await postHookEvent(
server,
buildBody(
{ hook_event_name, session_id: 'next-session', prompt: 'next task' },
{ paneKey: PANE }
)
)
expect(response.status).toBe(204)
}
// Why: a stranded restored child would hold the next agent's finished turn `working`.
expect(paneState(server)).toBe('done')
} finally {
server.stop()
}
})
it('never treats a hydrated resume remnant as a candidate', async () => {
const server = await restartWith([{ agentType: 'codex', providerSessionOnly: true }])
const probePtyLiveness = vi.fn(() => false)
try {
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
providerSessionOnly: true,
state: 'working'
})
expect(await sweepWith(server, { probePtyLiveness })).toBe(0)
expect(probePtyLiveness).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it.each(['waiting', 'blocked'] as const)(
'retires a restored %s row rather than inventing a completion',
async (state) => {
const server = await restartWith([{ state }])
try {
expect(await sweepWith(server)).toBe(1)
expect(paneState(server)).toBe('missing')
} finally {
server.stop()
}
}
)
it('probes a PTY bound in this runtime before the persisted one, once per PTY', async () => {
const server = await restartWith([{}, { paneKey: OTHER_PANE }])
const probePtyLiveness = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
probePtyLiveness,
boundPtyIdByPaneKey: { [PANE]: PTY, [OTHER_PANE]: PTY },
persistedPtyIdByPaneKey: { [PANE]: 'stale', [OTHER_PANE]: 'stale' }
})
).toBe(2)
expect(probePtyLiveness).toHaveBeenCalledExactlyOnceWith(PTY)
} finally {
server.stop()
}
})
it.each([
['is live', () => true],
['cannot be proven either way', () => null],
[
'throws',
() => {
throw new Error('daemon unreachable')
}
]
])('keeps the row when the PTY probe %s', async (_label, probePtyLiveness) => {
const server = await restartWith([{ agentType: 'codex' }])
try {
expect(await sweepWith(server, { probePtyLiveness })).toBe(0)
expect(paneState(server)).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane with no PTY binding to probe', async () => {
const server = await restartWith([{ agentType: 'codex' }])
const probePtyLiveness = vi.fn(() => false)
try {
expect(await sweepWith(server, { probePtyLiveness, persistedPtyIdByPaneKey: {} })).toBe(0)
expect(probePtyLiveness).not.toHaveBeenCalled()
expect(paneState(server)).toBe('working')
} finally {
server.stop()
}
})
it('never retires an SSH relay row, whose PTY is not in the local provider', async () => {
const server = await restartWith([{ agentType: 'codex', connectionId: 'conn-1' }])
const probePtyLiveness = vi.fn(() => false)
try {
expect(await sweepWith(server, { probePtyLiveness })).toBe(0)
expect(probePtyLiveness).not.toHaveBeenCalled()
expect(paneState(server)).toBe('working')
} finally {
server.stop()
}
})
it.each([
['an SSH', 'ssh:conn-1', toAppSshPtyId('conn-1', PTY)],
['a paired runtime', 'runtime:ephemeral-vm-1', 'remote:ephemeral-vm-1@@pty-1']
])('never retires a row in %s workspace', async (_label, executionHostId, ptyId) => {
const server = await restartWith([{ agentType: 'codex' }])
const probePtyLiveness = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
executionHostId,
probePtyLiveness,
persistedPtyIdByPaneKey: { [PANE]: ptyId }
})
).toBe(0)
expect(probePtyLiveness).not.toHaveBeenCalled()
expect(paneState(server)).toBe('working')
} finally {
server.stop()
}
})
it('leaves a restored done row alone', async () => {
const server = await restartWith([{ agentType: 'codex', state: 'done' }])
const probePtyLiveness = vi.fn(() => false)
try {
expect(await sweepWith(server, { probePtyLiveness })).toBe(0)
expect(probePtyLiveness).not.toHaveBeenCalled()
expect(paneState(server)).toBe('done')
} finally {
server.stop()
}
})
it('skips a pane that has reported to this runtime', async () => {
const server = await restartWith([{ agentType: 'codex' }])
const probePtyLiveness = vi.fn(() => false)
try {
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'round two', agentType: 'codex' }
})
expect(await sweepWith(server, { probePtyLiveness })).toBe(0)
expect(probePtyLiveness).not.toHaveBeenCalled()
expect(paneState(server)).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that reports while its probe is pending', async () => {
const server = await restartWith([{ agentType: 'codex' }])
const probe = deferredProbe()
try {
const sweep = sweepWith(server, { probePtyLiveness: probe.probePtyLiveness })
await vi.waitFor(() => expect(probe.probePtyLiveness).toHaveBeenCalledOnce())
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'confirmed during probe', agentType: 'codex' }
})
probe.resolve(false)
expect(await sweep).toBe(0)
expect(paneState(server)).toBe('working')
} finally {
server.stop()
}
})
it.each([
['binds a newer PTY', { [PANE]: PTY }, 'wt-1__pty-new'],
['rebinds its persisted PTY', {}, PTY]
])(
'keeps a pane that %s while its probe is pending',
async (_label, initialBinding: Record<string, string>, reboundPtyId) => {
const server = await restartWith([{ agentType: 'codex' }])
const boundPtyIdByPaneKey = { ...initialBinding }
const probe = deferredProbe()
try {
const sweep = sweepWith(server, {
probePtyLiveness: probe.probePtyLiveness,
boundPtyIdByPaneKey
})
await vi.waitFor(() => expect(probe.probePtyLiveness).toHaveBeenCalledExactlyOnceWith(PTY))
boundPtyIdByPaneKey[PANE] = reboundPtyId
probe.resolve(false)
expect(await sweep).toBe(0)
expect(paneState(server)).toBe('working')
} finally {
server.stop()
}
}
)
it('keeps a rebind that lands after its own probe but before the batch settles', async () => {
const otherPty = 'wt-1__pty-2'
const server = await restartWith([{}, { paneKey: OTHER_PANE }])
const boundPtyIdByPaneKey: Record<string, string> = {}
const probe = deferredProbe()
const probePtyLiveness = vi.fn((ptyId: string) =>
ptyId === PTY ? Promise.resolve(false) : probe.probePtyLiveness(ptyId)
)
try {
const sweep = sweepWith(server, {
probePtyLiveness,
boundPtyIdByPaneKey,
persistedPtyIdByPaneKey: { [PANE]: PTY, [OTHER_PANE]: otherPty }
})
await vi.waitFor(() => expect(probePtyLiveness).toHaveBeenCalledTimes(2))
boundPtyIdByPaneKey[PANE] = PTY
probe.resolve(false)
expect(await sweep).toBe(1)
expect(paneState(server)).toBe('working')
expect(paneState(server, OTHER_PANE)).toBe('missing')
} finally {
server.stop()
}
})
})
describe('indexPersistedPaneKeyPtyIds', () => {
it('maps layout leaves to pane keys and ignores empty bindings', () => {
expect(
indexPersistedPaneKeyPtyIds({
'tab-1': { ptyIdsByLeafId: { [LEAF]: PTY, 'leaf-empty': '' } },
'tab-2': undefined,
'tab-3': {}
})
).toEqual(new Map([[PANE, PTY]]))
})
})
describe('resolveAgentWorkspaceExecutionHostId', () => {
const localRepo = {
id: 'local-repo',
connectionId: null,
executionHostId: 'local' as const
}
const runtimeRepo = {
id: 'runtime-repo',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1' as const
}
const futureHostRepo = {
id: 'future-repo',
connectionId: null,
executionHostId: 'container:future-host'
}
const deps = {
getRepo: (repoId: string) =>
[localRepo, runtimeRepo, futureHostRepo].find((candidate) => candidate.id === repoId),
getWorktreeMeta: (worktreeId: string) => {
if (worktreeId === 'local-repo::/runtime-worktree') {
return { hostId: 'runtime:worktree-owner' }
}
if (worktreeId === 'runtime-repo::/local-worktree') {
return { hostId: 'local' }
}
if (worktreeId === 'local-repo::/future-worktree') {
return { hostId: 'container:future-host' }
}
return undefined
},
getFolderWorkspace: (id: string) =>
id === 'folder-runtime' ? { projectGroupId: 'group-runtime', connectionId: null } : undefined,
getProjectGroups: () => [
{
id: 'group-runtime',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1'
}
]
}
it('positively identifies local ownership and rejects runtime hosts', () => {
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/repo', deps)).toBe('local')
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/repo', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('folder:folder-runtime', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/runtime-worktree', deps)).toBe(
'runtime:worktree-owner'
)
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/local-worktree', deps)).toBe(
'local'
)
expect(resolveAgentWorkspaceExecutionHostId('future-repo::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/future-worktree', deps)).toBeNull()
expect(isLocalExecutionHost('local')).toBe(true)
expect(isLocalExecutionHost('runtime:ephemeral-vm-1')).toBe(false)
})
it('treats missing workspace provenance as unknown', () => {
expect(resolveAgentWorkspaceExecutionHostId('missing::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId(undefined, deps)).toBeNull()
expect(isLocalExecutionHost(null)).toBe(false)
})
})
@@ -7,56 +7,64 @@ import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { isLocalHookConnectionId } from '../../shared/wsl-hook-relay-contract'
import type { AgentHookServer } from './server'
export type RestoredSubagentLivenessSweepDeps = {
/** Targeted provider liveness, or null when the provider cannot prove either state. */
probeLiveLocalPty: (ptyId: string) => Promise<boolean | null>
type RestoredStatusSweepServer = Pick<
AgentHookServer,
'listUnconfirmedRestoredStatuses' | 'reconcileEndedProcessForPaneKeys'
>
export type RestoredStatusDeadTerminalSweepDeps = {
server: RestoredStatusSweepServer
/** Targeted provider liveness: true live, false absent, null when it cannot prove either. */
probePtyLiveness: (ptyId: string) => Promise<boolean | null>
isLocalExecutionHost: (worktreeId: string | undefined) => boolean
/** PTY bound to this pane in the current session, if it has one. */
getBoundPtyIdForPaneKey: (paneKey: string) => string | undefined
/** PTY this pane was bound to when the session was last persisted; covers panes
* whose surviving daemon session has not been reattached yet. */
getPersistedPtyIdForPaneKey: (paneKey: string) => string | undefined
reap: (
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
) => Promise<number>
}
/** Drop restored rows only when the owning host is local and its provider proves
* the exact PTY absent. */
export async function sweepRestoredSubagentsWithoutLiveAgent(
deps: RestoredSubagentLivenessSweepDeps
/** A terminal that dies while Orca is down never reaches the PTY-exit teardown, so its restored
* row would claim work forever. Stand in for that exit: retire restored rows whose local PTY
* the provider proves absent, through the same path an observed exit takes. */
export async function retireRestoredStatusesWithDeadTerminals(
deps: RestoredStatusDeadTerminalSweepDeps
): Promise<number> {
const probesByPtyId = new Map<string, Promise<boolean | null>>()
const boundPtyIdAtProbeByPaneKey = new Map<string, string | undefined>()
return await deps.reap(
(worktreeId) => deps.isLocalExecutionHost(worktreeId),
async (paneKey) => {
const boundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
boundPtyIdAtProbeByPaneKey.set(paneKey, boundPtyId)
const ptyId = boundPtyId ?? deps.getPersistedPtyIdForPaneKey(paneKey)
if (!ptyId) {
return true
}
try {
let probe = probesByPtyId.get(ptyId)
if (!probe) {
probe = deps.probeLiveLocalPty(ptyId)
probesByPtyId.set(ptyId, probe)
}
const live = await probe
const currentBoundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
// Why: cold restore can rebind the persisted id while its absence probe is in flight.
return currentBoundPtyId !== boundPtyId || live !== false
} catch {
return true
}
},
(paneKey) =>
!boundPtyIdAtProbeByPaneKey.has(paneKey) ||
deps.getBoundPtyIdForPaneKey(paneKey) === boundPtyIdAtProbeByPaneKey.get(paneKey)
const isProvenAbsent = async (ptyId: string): Promise<boolean> => {
let probe = probesByPtyId.get(ptyId)
if (!probe) {
// Why: a probe that throws is unverifiable, never evidence the terminal died.
probe = deps.probePtyLiveness(ptyId).catch(() => null)
probesByPtyId.set(ptyId, probe)
}
return (await probe) === false
}
const candidates = deps.server.listUnconfirmedRestoredStatuses().filter(
(row) =>
// Why: only a local pane's PTY lives in this machine's provider.
isLocalHookConnectionId(row.connectionId) && deps.isLocalExecutionHost(row.worktreeId)
)
const probed = await Promise.all(
candidates.map(async (row) => {
const boundPtyId = deps.getBoundPtyIdForPaneKey(row.paneKey)
const ptyId = boundPtyId ?? deps.getPersistedPtyIdForPaneKey(row.paneKey)
return { row, boundPtyId, absent: ptyId !== undefined && (await isProvenAbsent(ptyId)) }
})
)
// Why: a pane can report, or cold restore can rebind its PTY, while the probes are in flight.
const stillRestored = new Set(deps.server.listUnconfirmedRestoredStatuses())
const ended = probed.filter(
({ row, boundPtyId, absent }) =>
absent && stillRestored.has(row) && deps.getBoundPtyIdForPaneKey(row.paneKey) === boundPtyId
)
// Why: the conversation outlived its terminal, so keep it resumable.
return deps.server.reconcileEndedProcessForPaneKeys(
ended.map(({ row }) => row.paneKey),
{ preserveResumeIdentity: true }
)
}
@@ -1,600 +0,0 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSubagentSnapshot } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { toAppSshPtyId } from '../../shared/ssh-pty-id'
import { AgentHookServer } from './server'
import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
resolveAgentWorkspaceExecutionHostId,
sweepRestoredSubagentsWithoutLiveAgent
} from './restored-subagent-liveness-sweep'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
const PTY = 'wt-1__pty-1'
const WORKING_CHILD: AgentSubagentSnapshot = {
id: 'areview-loop-c237a4c577493352',
state: 'working',
startedAt: 1_000
}
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-restored-subagent-'))
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(dir, { recursive: true, force: true })
})
/** Persist a pane whose lead has finished but whose roster still holds a working
* child, then restart into a fresh server — the shape a machine sleep leaves
* behind when the child's SubagentStop is lost while Orca is down. */
async function restartWithInFlightSubagent(options?: {
connectionId?: string
state?: 'working' | 'waiting'
subagents?: AgentSubagentSnapshot[]
additionalPaneKey?: string
}): Promise<AgentHookServer> {
const first = new AgentHookServer()
await first.start({ env: 'production', userDataPath: dir })
first.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: options?.connectionId ?? null,
payload: {
state: options?.state ?? 'working',
prompt: 'review the PR',
agentType: 'claude',
subagents: options?.subagents ?? [WORKING_CHILD]
}
})
if (options?.additionalPaneKey) {
first.ingestTerminalStatus({
paneKey: options.additionalPaneKey,
tabId: 'tab-2',
worktreeId: 'wt-1',
connectionId: options.connectionId ?? null,
payload: {
state: options.state ?? 'working',
prompt: 'review the PR',
agentType: 'claude',
subagents: options.subagents ?? [WORKING_CHILD]
}
})
}
first.flushStatusPersistSync()
first.stop()
const restarted = new AgentHookServer()
await restarted.start({ env: 'production', userDataPath: dir })
return restarted
}
function sweepWith(
server: AgentHookServer,
overrides: {
probeLiveLocalPty?: (ptyId: string) => boolean | null | Promise<boolean | null>
executionHostId?: string | null
boundPtyIdByPaneKey?: Record<string, string>
persistedPtyIdByPaneKey?: Record<string, string>
} = {}
): Promise<number> {
return sweepRestoredSubagentsWithoutLiveAgent({
probeLiveLocalPty: async (ptyId) =>
overrides.probeLiveLocalPty ? await overrides.probeLiveLocalPty(ptyId) : false,
isLocalExecutionHost: () =>
isLocalExecutionHost(
overrides.executionHostId === undefined ? 'local' : overrides.executionHostId
),
getBoundPtyIdForPaneKey: (paneKey) => overrides.boundPtyIdByPaneKey?.[paneKey],
getPersistedPtyIdForPaneKey: (paneKey) => overrides.persistedPtyIdByPaneKey?.[paneKey],
reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) =>
server.reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalHost,
isLocalPaneAgentLive,
isLocalPaneLivenessEvidenceCurrent
)
})
}
function paneStatus(
server: AgentHookServer,
paneKey = PANE
): { state: string; subagents?: AgentSubagentSnapshot[] } {
const entry = server.getStatusSnapshotForPane(paneKey)[0]
return { state: entry?.state ?? 'missing', subagents: entry?.subagents }
}
describe('restored subagent liveness sweep', () => {
it('reaps the phantom seed so a slept-through pane reaches done', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
const previous = server.getStatusSnapshotForPane(PANE)[0]
expect(previous?.restoredUnconfirmed).toBe(true)
const previousReceivedAt = previous?.receivedAt ?? 0
const reconciledAt = previousReceivedAt + 1
const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt)
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
expect(paneStatus(server)).toEqual({ state: 'done', subagents: undefined })
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
receivedAt: reconciledAt,
stateStartedAt: reconciledAt
})
// Why: the reconciled `done` is process-probe-verified, so it must shed the hydrated-unconfirmed marker.
expect(server.getStatusSnapshotForPane(PANE)[0]?.restoredUnconfirmed).toBeUndefined()
now.mockRestore()
} finally {
server.stop()
}
})
it('keeps a seed whose pane still has a live local PTY', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: (ptyId) => ptyId === PTY,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
} finally {
server.stop()
}
})
it('preserves timing when reaping rows does not change the lead state', async () => {
const server = await restartWithInFlightSubagent({ state: 'waiting' })
try {
const previous = server.getStatusSnapshotForPane(PANE)[0]
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
state: 'waiting',
subagents: undefined,
receivedAt: previous?.receivedAt,
stateStartedAt: previous?.stateStartedAt,
// Why: a still-nonterminal reconciled row remains unconfirmed — only hooks or a terminal fact confirm it.
restoredUnconfirmed: true
})
} finally {
server.stop()
}
})
it('uses targeted liveness for a PTY bound in this runtime', async () => {
const server = await restartWithInFlightSubagent()
try {
const probeLiveLocalPty = vi.fn((ptyId: string) => ptyId === PTY)
expect(
await sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('probes only restored rosters and deduplicates a shared exact PTY', async () => {
const otherPane = makePaneKey('tab-2', LEAF)
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: PTY }
})
).toBe(2)
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
} finally {
server.stop()
}
})
it('reaps a hydrated non-terminal row with no roster at all (STA-4612)', async () => {
// A restored roster is only one shape of stranded claim. A lead row left `working` with no
// children has nothing left to retire it — no child event, no inventory, no hook — so it holds
// the pane working for good and locks its agent out of hibernation.
const server = await restartWithInFlightSubagent({ subagents: [] })
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(1)
expect(probeLiveLocalPty).toHaveBeenCalled()
expect(paneStatus(server).state).toBe('missing')
} finally {
server.stop()
}
})
it('keeps a hydrated non-terminal row with no roster when its PTY is still live', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => true,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a hydrated non-terminal row with no roster when the probe cannot prove either state', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => null,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('never reaps an SSH-launched pane, whose agent cannot appear in a local scan', async () => {
const sshPtyId = toAppSshPtyId('conn-1', PTY)
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
executionHostId: 'ssh:conn-1',
persistedPtyIdByPaneKey: { [PANE]: sshPtyId }
})
).toBe(0)
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
} finally {
server.stop()
}
})
it('never reaps a relay-owned pane even with no local PTY at all', async () => {
const server = await restartWithInFlightSubagent({ connectionId: 'conn-1' })
try {
expect(await sweepWith(server)).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('never reaps a runtime-hosted pane from the desktop local provider', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
executionHostId: 'runtime:ephemeral-vm-1',
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: 'remote:ephemeral-vm-1@@pty-1' }
})
).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('does nothing when targeted PTY liveness is unknown', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => null,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('does nothing without an exact pane PTY binding', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(await sweepWith(server, { probeLiveLocalPty })).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('skips panes that have reported to this runtime', async () => {
const server = await restartWithInFlightSubagent()
try {
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'now reviewing round two',
agentType: 'claude',
subagents: [WORKING_CHILD]
}
})
expect(await sweepWith(server)).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that reports while its liveness probe is pending', async () => {
const server = await restartWithInFlightSubagent()
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledOnce())
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'confirmed during probe',
agentType: 'claude',
subagents: [WORKING_CHILD]
}
})
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that binds a newer PTY while the old probe is pending', async () => {
const server = await restartWithInFlightSubagent()
const boundPtyIdByPaneKey = { [PANE]: PTY }
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, { probeLiveLocalPty, boundPtyIdByPaneKey })
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
boundPtyIdByPaneKey[PANE] = 'wt-1__pty-new'
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that rebinds its persisted PTY while the probe is pending', async () => {
const server = await restartWithInFlightSubagent()
const boundPtyIdByPaneKey: Record<string, string> = {}
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
boundPtyIdByPaneKey[PANE] = PTY
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a same-id rebind that lands after its probe but before the batch settles', async () => {
const otherPane = makePaneKey('tab-2', LEAF)
const otherPty = 'wt-1__pty-2'
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
const boundPtyIdByPaneKey: Record<string, string> = {}
let resolveOtherProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn((ptyId: string) =>
ptyId === PTY
? Promise.resolve(false)
: new Promise<boolean | null>((resolve) => {
resolveOtherProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey,
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: otherPty }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledTimes(2))
boundPtyIdByPaneKey[PANE] = PTY
resolveOtherProbe(false)
expect(await sweep).toBe(1)
expect(paneStatus(server).state).toBe('working')
expect(paneStatus(server, otherPane).state).toBe('done')
} finally {
server.stop()
}
})
it('leaves a pane whose lead is genuinely mid-turn at working', async () => {
const server = await restartWithInFlightSubagent()
try {
// Why: the lead's own tool event proves the process is alive; only the
// child-gated 'working' may be re-derived, so the state must survive.
server.ingestTerminalStatus({
paneKey: makePaneKey('tab-2', LEAF),
tabId: 'tab-2',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'still going', agentType: 'claude' }
})
await sweepWith(server)
expect(paneStatus(server, makePaneKey('tab-2', LEAF)).state).toBe('working')
} finally {
server.stop()
}
})
it('reports zero and leaves state alone when targeted liveness throws', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => {
throw new Error('daemon unreachable')
})
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
})
describe('indexPersistedPaneKeyPtyIds', () => {
it('maps layout leaves to pane keys and ignores empty bindings', () => {
expect(
indexPersistedPaneKeyPtyIds({
'tab-1': { ptyIdsByLeafId: { [LEAF]: PTY, 'leaf-empty': '' } },
'tab-2': undefined,
'tab-3': {}
})
).toEqual(new Map([[PANE, PTY]]))
})
})
describe('resolveAgentWorkspaceExecutionHostId', () => {
const localRepo = {
id: 'local-repo',
connectionId: null,
executionHostId: 'local' as const
}
const runtimeRepo = {
id: 'runtime-repo',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1' as const
}
const futureHostRepo = {
id: 'future-repo',
connectionId: null,
executionHostId: 'container:future-host'
}
const deps = {
getRepo: (repoId: string) =>
[localRepo, runtimeRepo, futureHostRepo].find((candidate) => candidate.id === repoId),
getWorktreeMeta: (worktreeId: string) => {
if (worktreeId === 'local-repo::/runtime-worktree') {
return { hostId: 'runtime:worktree-owner' }
}
if (worktreeId === 'runtime-repo::/local-worktree') {
return { hostId: 'local' }
}
if (worktreeId === 'local-repo::/future-worktree') {
return { hostId: 'container:future-host' }
}
return undefined
},
getFolderWorkspace: (id: string) =>
id === 'folder-runtime' ? { projectGroupId: 'group-runtime', connectionId: null } : undefined,
getProjectGroups: () => [
{
id: 'group-runtime',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1'
}
]
}
it('positively identifies local ownership and rejects runtime hosts', () => {
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/repo', deps)).toBe('local')
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/repo', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('folder:folder-runtime', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/runtime-worktree', deps)).toBe(
'runtime:worktree-owner'
)
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/local-worktree', deps)).toBe(
'local'
)
expect(resolveAgentWorkspaceExecutionHostId('future-repo::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/future-worktree', deps)).toBeNull()
expect(isLocalExecutionHost('local')).toBe(true)
expect(isLocalExecutionHost('runtime:ephemeral-vm-1')).toBe(false)
})
it('treats missing workspace provenance as unknown', () => {
expect(resolveAgentWorkspaceExecutionHostId('missing::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId(undefined, deps)).toBeNull()
expect(isLocalExecutionHost(null)).toBe(false)
})
})
@@ -13,7 +13,7 @@ import {
type ExecutionHostScope
} from '../../../shared/execution-host'
import { worktreeIdsEqual } from '../../../shared/worktree/id'
import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract'
import { isLocalHookConnectionId } from '../../../shared/wsl-hook-relay-contract'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AgentHookServerAuthorityFences } from './server-authority-fences'
@@ -126,14 +126,11 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
paneKeys: Iterable<string>,
options?: {
endedPresence?: AgentProcessPresence
/** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still
* be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop`
* minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to
* resume into, and dropping it matches what `clearProviderPtyState` already does. */
/** The pane survives to resume into (a shell outlived the agent, or a pane restored after its
* terminal died offline), so keep the `providerSessionOnly` remnant. */
preserveResumeIdentity?: boolean
}
): number {
// A certified PTY exit passes no resume identity; a surviving shell may opt into the remnant.
let cleared = 0
for (const paneKey of paneKeys) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
@@ -191,8 +188,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
(!parsed ||
(parsed.kind === 'ssh'
? claim.connectionId === parsed.targetId
: // Why: WSL panes are local; their relay only stamps transport provenance.
claim.connectionId === null || isWslHookRelayConnectionId(claim.connectionId)))
: isLocalHookConnectionId(claim.connectionId)))
// The startup snapshot may outlive its replaced map entry; revoke only the removed owner.
for (const commitment of this.hydratedAuthorityCommitments) {
if (ownedByRemoved(commitment)) {
@@ -11,7 +11,7 @@ import {
import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state'
import { AGENT_STATUS_PERSISTED_HYDRATION_MODE } from '../../../shared/agent-status-legacy-adapter'
import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants'
import type { LastStatusFile } from './server-types'
import type { EnrichedAgentHookEventPayload, LastStatusFile } from './server-types'
import {
authorityCommitmentsMatch,
dropHydratedIdleClaudeSubagents,
@@ -19,9 +19,9 @@ import {
sanitizeHydratedEntry,
sanitizePersistedAuthorityCommitment
} from './server-persistence-validation'
import { AgentHookServerReaping } from './server-reaping'
import { AgentHookServerTabCleanup } from './server-tab-cleanup'
export abstract class AgentHookServerHydration extends AgentHookServerReaping {
export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup {
/** Hydrate the durable cache, validating every row before it reaches the live listener state. */
protected hydrateLastStatusFromDisk(): void {
if (!this.lastStatusFilePath) {
@@ -167,4 +167,22 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping {
this.primeStatusPersistBaseline(raw)
}
}
/** Hydrated rows still claiming work that nothing in this runtime has confirmed. */
listUnconfirmedRestoredStatuses(): EnrichedAgentHookEventPayload[] {
const rows: EnrichedAgentHookEventPayload[] = []
for (const [paneKey, row] of this.state.lastStatusByPaneKey) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type.
const enriched = row as EnrichedAgentHookEventPayload
// Why: a resume remnant carries no state claim.
if (
enriched.restoredUnconfirmed &&
!enriched.providerSessionOnly &&
!this.runtimeObservedStatusPaneKeys.has(paneKey)
) {
rows.push(enriched)
}
}
return rows
}
}
@@ -1,132 +0,0 @@
import {
claudeRosterHasRestoredSnapshotSubagent,
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../../shared/claude-subagent-roster'
import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state'
import { reapRestoredClaudeSubagentsForDeadPane } from '../../../shared/agent-hook-listener/providers/claude-roster-state'
import { AGENT_STATUS_PERSISTED_HYDRATION_MODE } from '../../../shared/agent-status-legacy-adapter'
import { AgentHookServerTabCleanup } from './server-tab-cleanup'
import type { EnrichedAgentHookEventPayload } from './server-types'
export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup {
/** Second reap path for restored Claude subagent rows: drop the ones whose pane
* has no live local agent process behind it any more. A PTY that dies while Orca
* is down never runs the teardown that clears pane state, so hydrate rebuilds a
* roster nothing can ever retire — the inventory reap needs the parent to emit a
* complete `background_tasks` list and an idle parent never does. The row then
* gates the pane 'working' for the rest of its life and hibernation, which
* requires 'done', can never reclaim the agent's heap.
*
* Both the execution host and relay binding must prove local ownership before
* targeted PTY liveness is consulted. Panes that reported in this runtime are
* also skipped. Returns the number of panes changed. */
async reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
): Promise<number> {
const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = []
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
const enriched = entry as EnrichedAgentHookEventPayload
if (
enriched.payload.agentType === 'claude' &&
enriched.connectionId === null &&
isLocalExecutionHost(enriched.worktreeId) &&
// Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal,
// or a background-task/cron latch nothing will refresh, strands the pane just as
// permanently — and unlike the roster case there is no child event left to reap it.
(claudeRosterHasRestoredSnapshotSubagent(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
) ||
enriched.payload.state !== 'done' ||
this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) &&
!this.runtimeObservedStatusPaneKeys.has(paneKey)
) {
candidates.push({ paneKey, entry: enriched })
}
}
const liveness = await Promise.all(
candidates.map(async (candidate) => {
try {
return await isLocalPaneAgentLive(candidate.paneKey)
} catch {
return true
}
})
)
let changedPanes = 0
for (const [index, candidate] of candidates.entries()) {
const { paneKey, entry: enriched } = candidate
if (
liveness[index] ||
!isLocalPaneLivenessEvidenceCurrent(paneKey) ||
this.state.lastStatusByPaneKey.get(paneKey) !== enriched ||
this.runtimeObservedStatusPaneKeys.has(paneKey) ||
!isLocalExecutionHost(enriched.worktreeId)
) {
continue
}
if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) {
// Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably
// gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the
// pane the same way an observed exit would — otherwise the widened candidate set is inert.
//
// Why delete rather than downgrade to `done` like the reap branch below: that branch has a
// real turn to describe — a parent whose children it just reaped — while these panes' only
// claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would
// invent a completion that never happened, and leaving it non-terminal keeps the bug. This
// sweep stands in for the exit Orca never observed, so it does what that exit does:
// `clearProviderPtyState` -> `clearPaneState`.
if (this.hasLiveClaimsForPaneKey(paneKey)) {
this.clearPaneState(paneKey)
changedPanes += 1
}
continue
}
changedPanes += 1
const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey)
const subagents = claudeRosterToSnapshots(roster)
// Why: the pane's persisted 'working' was the child gate holding a finished
// lead open (subagent events never set lead state). With the last working row
// gone and no process left to report, 'done' is the only truthful state — and
// the one hibernation needs once this pane's agent is restored.
const state =
enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster)
? 'done'
: enriched.payload.state
const stateChanged = state !== enriched.payload.state
const reconciledAt = stateChanged
? Math.max(Date.now(), enriched.receivedAt + 1)
: enriched.receivedAt
// Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying
// restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion.
const { restoredUnconfirmed, ...reconciledBase } = enriched
const reconciled: EnrichedAgentHookEventPayload = {
...reconciledBase,
...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
receivedAt: reconciledAt,
stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt,
payload: {
...enriched.payload,
state,
workingMode: state === 'working' ? enriched.payload.workingMode : undefined,
subagents
}
}
admitLegacyAgentStatus(
this.state,
'main-restored-status-reaping',
reconciled,
AGENT_STATUS_PERSISTED_HYDRATION_MODE
)
this.commitStatusRowMutation(enriched, reconciled)
}
if (changedPanes > 0) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
return changedPanes
}
}
@@ -5,7 +5,7 @@ import {
} from '../../shared/agent-status-types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import { agentVerdictFields } from '../../shared/agent-main-agent-verdict'
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
import { isLocalHookConnectionId } from '../../shared/wsl-hook-relay-contract'
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
export type ConnectedPtyEvidence = {
@@ -69,7 +69,7 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
source.structuredHost === undefined &&
tabId !== undefined &&
mirroredWorktreeId === undefined &&
(source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) &&
isLocalHookConnectionId(source.connectionId) &&
!args.connectedPtyEvidence.tabIds.has(tabId) &&
!args.connectedPtyEvidence.paneKeys.has(source.paneKey) &&
// Resolved only from a connected PTY's handle, so its presence is the liveness evidence.
+11 -23
View File
@@ -26,8 +26,8 @@ import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
resolveAgentWorkspaceExecutionHostId,
sweepRestoredSubagentsWithoutLiveAgent
} from '../agent-hooks/restored-subagent-liveness-sweep'
retireRestoredStatusesWithDeadTerminals
} from '../agent-hooks/restored-status-dead-terminal-sweep'
import { startFirstWindowStartupServices } from './first-window-startup-services'
import { logStartupMilestone } from './startup-diagnostics'
import type { WindowsDesktopStartupServices } from './windows-desktop-shell-path-startup'
@@ -83,25 +83,19 @@ export function handlePtyExit(id: string, exitSequence: number): void {
state.codexSessionMigration?.finishLaunch(id, exitSequence)
}
/** A PTY that dies while Orca is down never runs the teardown that clears pane
* state, so hydrate can rebuild a Claude subagent roster that no later hook can
* retire — pinning the pane 'working' and locking its agent out of hibernation
* for good. Once provider and hook hydration settle, targeted PTY liveness can
* retire only rows whose local owner is proven gone. */
export async function reapRestoredSubagentsWithoutLiveAgent(): Promise<void> {
/** Runs once provider and hook hydration settle, so targeted PTY liveness can answer. */
async function sweepRestoredStatusesAgainstLocalPtys(): Promise<void> {
const store = state.store
if (!store) {
return
}
const provider = getDaemonProvider()
if (!provider) {
if (!store || !provider) {
return
}
const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds(
store.getWorkspaceSession().terminalLayoutsByTabId ?? {}
)
await sweepRestoredSubagentsWithoutLiveAgent({
probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId),
await retireRestoredStatusesWithDeadTerminals({
server: agentHookServer,
probePtyLiveness: (ptyId) => provider.probePtyLiveness(ptyId),
isLocalExecutionHost: (worktreeId) =>
isLocalExecutionHost(
resolveAgentWorkspaceExecutionHostId(worktreeId, {
@@ -112,13 +106,7 @@ export async function reapRestoredSubagentsWithoutLiveAgent(): Promise<void> {
})
),
getBoundPtyIdForPaneKey: getPtyIdForPaneKey,
getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey),
reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) =>
agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalHost,
isLocalPaneAgentLive,
isLocalPaneLivenessEvidenceCurrent
)
getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey)
})
}
@@ -207,8 +195,8 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ
)
void startupServices.localPtyReady.then(() => {
logStartupMilestone('local-pty-startup-ready')
void reapRestoredSubagentsWithoutLiveAgent().catch((error) =>
console.warn('[agent-hooks] restored-subagent liveness probe failed:', error)
void sweepRestoredStatusesAgainstLocalPtys().catch((error) =>
console.warn('[agent-hooks] restored-status dead-terminal sweep failed:', error)
)
})
return startupServices
@@ -5,10 +5,7 @@ import type {
AgentWorkingMode
} from '../../agent-status-types'
import { continueMainAgentStatus, mainAgentTurnInterrupted } from '../../agent-lead-status-fold'
import {
reapUnconfirmedRestoredClaudeSubagents,
type ClaudeSubagentRoster
} from '../../claude-subagent-roster'
import type { ClaudeSubagentRoster } from '../../claude-subagent-roster'
import type { AgentHookEventPayload } from '../listener-event'
import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state'
import { readString } from '../tool-input-preview'
@@ -211,7 +208,7 @@ export function seedClaudeSubagentRosterFromSnapshots(
description: snapshot.description,
// Why: the seed can be a phantom (child finished while Orca was down, SubagentStop lost); let a PRESENT background_tasks list omitting the id remove it, not gate the pane 'working' forever.
backgroundTasksAuthoritative: true,
// Why: an idle parent never emits that list, so the inventory reap alone can strand the seed; mark it for the liveness reap below.
// Why: no child event in this runtime has confirmed the seed, so it must not count as live child work.
restoredFromSnapshot: true
})
}
@@ -252,25 +249,6 @@ export function seedClaudeLeadTurnFromPersistedStatus(
}
}
/** Reap this pane's unconfirmed restored seeds because no live agent process backs
* the pane any more (its PTY died while Orca was down, so no finish hook could
* arrive). Callers must have proven the pane is LOCAL-launched — a remote/SSH
* agent runs on the far host and can never appear in a local process index.
* Returns whether the roster changed. */
export function reapRestoredClaudeSubagentsForDeadPane(
state: HookListenerState,
paneKey: string
): boolean {
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
if (!roster || !reapUnconfirmedRestoredClaudeSubagents(roster)) {
return false
}
if (roster.size === 0) {
state.claudeSubagentRosterByPaneKey.delete(paneKey)
}
return true
}
/** Drop a child-owned waiting state when the child stops/idles, restoring the displaced lead state. */
export function clearClaudePendingWaitForAgent(
state: HookListenerState,
@@ -5,7 +5,6 @@ export type AgentStatusLegacyIngressCaller =
| 'main-status-cleanup'
| 'main-pane-alias-transfer'
| 'main-status-hydration'
| 'main-restored-status-reaping'
| 'relay-status-cache'
| 'shared-bounded-status-cache'
@@ -68,15 +67,6 @@ export const AGENT_STATUS_LEGACY_INGRESS_MANIFEST = Object.freeze([
gate: 'Trusted adoption or bounded unconfirmed-observation retention expiry',
allowedModes: ['persisted-hydration']
}),
entry({
caller: 'main-restored-status-reaping',
sourcePath: 'src/main/agent-hooks/server/server-reaping.ts',
reason: 'Process-probe reconciliation can update a quarantined hydrated pane row.',
owner: 'main-agent-hooks',
destination: '6',
gate: 'Canonical hydration adoption fixtures and compatibility-branch ablation',
allowedModes: ['persisted-hydration']
}),
entry({
caller: 'relay-status-cache',
sourcePath: 'src/shared/agent-status-legacy-relay-cache.ts',
+1 -1
View File
@@ -480,7 +480,7 @@ describe('claude-subagent-roster', () => {
})
})
describe('restored-row liveness reap', () => {
describe('restored-row confirmation', () => {
const restored = (id: string): ClaudeSubagentRoster =>
new Map([
[
+2 -2
View File
@@ -88,8 +88,8 @@ export function upsertWorkingClaudeSubagent(
// background_tasks omission must stop reaping it (teammate-shaped ids
// never appear there). The fold re-tags its own recreations after this.
existing.backgroundTasksAuthoritative = undefined
// Why: the live event proves the agent process behind the restored row is
// still running it, so the liveness reap must stop treating it as a claim.
// Why: the live event confirms the restored row, so the manual-compact reap and
// runtime-working checks must treat it as live child work again.
existing.restoredFromSnapshot = undefined
return
}
+5
View File
@@ -88,6 +88,11 @@ export function isWslHookRelayConnectionId(value: string | null | undefined): bo
return typeof value === 'string' && value.startsWith(WSL_HOOK_RELAY_CONNECTION_PREFIX)
}
/** A hook row from this machine: unstamped, or WSL's relay, which only stamps transport provenance. */
export function isLocalHookConnectionId(value: string | null | undefined): boolean {
return value === null || isWslHookRelayConnectionId(value)
}
export function wslHookRelayEndpointFilePath(guestHome: string, instanceKey: string): string {
return `${wslHookRelayEndpointDir(guestHome, instanceKey)}/${WSL_HOOK_RELAY_ENDPOINT_FILE}`
}