diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 6ea0701fac8..924d3225032 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts b/src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts new file mode 100644 index 00000000000..682d30ad32b --- /dev/null +++ b/src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts @@ -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 { + 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 + executionHostId?: string | null + boundPtyIdByPaneKey?: Record + persistedPtyIdByPaneKey?: Record + } = {} +): Promise { + 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 Promise>> + resolve: (live: boolean | null) => void +} { + let resolve!: (live: boolean | null) => void + const probePtyLiveness = vi.fn( + () => + new Promise((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, 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 = {} + 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) + }) +}) diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts b/src/main/agent-hooks/restored-status-dead-terminal-sweep.ts similarity index 62% rename from src/main/agent-hooks/restored-subagent-liveness-sweep.ts rename to src/main/agent-hooks/restored-status-dead-terminal-sweep.ts index f183534e64f..94472280ff9 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts +++ b/src/main/agent-hooks/restored-status-dead-terminal-sweep.ts @@ -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 +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 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, - isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean - ) => Promise } -/** 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 { const probesByPtyId = new Map>() - const boundPtyIdAtProbeByPaneKey = new Map() - 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 => { + 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 } ) } diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts deleted file mode 100644 index b3257e6972c..00000000000 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ /dev/null @@ -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 { - 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 - executionHostId?: string | null - boundPtyIdByPaneKey?: Record - persistedPtyIdByPaneKey?: Record - } = {} -): Promise { - 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((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((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 = {} - let resolveProbe!: (live: boolean | null) => void - const probeLiveLocalPty = vi.fn( - () => - new Promise((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 = {} - let resolveOtherProbe!: (live: boolean | null) => void - const probeLiveLocalPty = vi.fn((ptyId: string) => - ptyId === PTY - ? Promise.resolve(false) - : new Promise((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) - }) -}) diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 862589a0750..2bdcdafac60 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -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, 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)) { diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index cdab5bd53b7..4cfee9efb34 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -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 + } } diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts deleted file mode 100644 index 13569aa6f0c..00000000000 --- a/src/main/agent-hooks/server/server-reaping.ts +++ /dev/null @@ -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, - isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean - ): Promise { - 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 - } -} diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index e03f458134f..f1775ea5fe7 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -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. diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 908a1dff166..5a639a7fa57 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -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 { +/** Runs once provider and hook hydration settle, so targeted PTY liveness can answer. */ +async function sweepRestoredStatusesAgainstLocalPtys(): Promise { 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 { }) ), 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 diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index bf25ec71cb1..a659c32ad52 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -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, diff --git a/src/shared/agent-status-legacy-ingress-manifest.ts b/src/shared/agent-status-legacy-ingress-manifest.ts index f14681a6783..4c2724d09be 100644 --- a/src/shared/agent-status-legacy-ingress-manifest.ts +++ b/src/shared/agent-status-legacy-ingress-manifest.ts @@ -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', diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index df327cde829..3ccb8b67a82 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.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([ [ diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index d338815fe26..533d6233f5d 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -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 } diff --git a/src/shared/wsl-hook-relay-contract.ts b/src/shared/wsl-hook-relay-contract.ts index 87f099e6e0d..d5acb05b6e6 100644 --- a/src/shared/wsl-hook-relay-contract.ts +++ b/src/shared/wsl-hook-relay-contract.ts @@ -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}` }