mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
+47
-39
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -480,7 +480,7 @@ describe('claude-subagent-roster', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('restored-row liveness reap', () => {
|
||||
describe('restored-row confirmation', () => {
|
||||
const restored = (id: string): ClaudeSubagentRoster =>
|
||||
new Map([
|
||||
[
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user