diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts new file mode 100644 index 00000000000..40824f1ee9f --- /dev/null +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -0,0 +1,238 @@ +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, + 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(() => { + 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 + subagents?: AgentSubagentSnapshot[] +}): 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: '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: { + livePtyIds?: readonly string[] | null + boundPtyIdByPaneKey?: Record + persistedPtyIdByPaneKey?: Record + } = {} +): Promise { + return sweepRestoredSubagentsWithoutLiveAgent({ + listLiveLocalPtyIds: async () => + overrides.livePtyIds === undefined ? [] : overrides.livePtyIds, + getBoundPtyIdForPaneKey: (paneKey) => overrides.boundPtyIdByPaneKey?.[paneKey], + getPersistedPtyIdForPaneKey: (paneKey) => overrides.persistedPtyIdByPaneKey?.[paneKey], + reap: (isLocalPaneAgentLive) => + server.reapRestoredClaudeSubagentsWithoutLiveAgent(isLocalPaneAgentLive) + }) +} + +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] }) + + await expect(sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).resolves.toBe(1) + + expect(paneStatus(server)).toEqual({ state: 'done', subagents: undefined }) + } finally { + server.stop() + } + }) + + it('keeps a seed whose pane still has a live local PTY', async () => { + const server = await restartWithInFlightSubagent() + try { + await expect( + sweepWith(server, { livePtyIds: [PTY], persistedPtyIdByPaneKey: { [PANE]: PTY } }) + ).resolves.toBe(0) + + expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] }) + } finally { + server.stop() + } + }) + + it('keeps a seed whose PTY is bound in this session but not listed yet', async () => { + const server = await restartWithInFlightSubagent() + try { + await expect( + sweepWith(server, { livePtyIds: [PTY], boundPtyIdByPaneKey: { [PANE]: PTY } }) + ).resolves.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 { + await expect( + sweepWith(server, { livePtyIds: [], persistedPtyIdByPaneKey: { [PANE]: sshPtyId } }) + ).resolves.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 { + await expect(sweepWith(server)).resolves.toBe(0) + + expect(paneStatus(server).state).toBe('working') + } finally { + server.stop() + } + }) + + it('does nothing when the PTY inventory cannot be read', async () => { + const server = await restartWithInFlightSubagent() + try { + await expect(sweepWith(server, { livePtyIds: null })).resolves.toBe(0) + + 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] + } + }) + + await expect(sweepWith(server)).resolves.toBe(0) + expect(paneStatus(server).state).toBe('working') + } 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 the listing throws', async () => { + const server = await restartWithInFlightSubagent() + const listLiveLocalPtyIds = vi.fn(async () => { + throw new Error('daemon unreachable') + }) + try { + await expect( + sweepRestoredSubagentsWithoutLiveAgent({ + listLiveLocalPtyIds, + getBoundPtyIdForPaneKey: () => undefined, + getPersistedPtyIdForPaneKey: () => undefined, + reap: (probe) => server.reapRestoredClaudeSubagentsWithoutLiveAgent(probe) + }) + ).rejects.toThrow('daemon unreachable') + + 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]])) + }) +}) diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts new file mode 100644 index 00000000000..04cb1f65c3e --- /dev/null +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts @@ -0,0 +1,59 @@ +import { parseAppSshPtyId } from '../../shared/ssh-pty-id' + +/** How long after startup the sweep runs. Terminal restore binds panes to their + * daemon sessions in a burst right after launch; waiting keeps a pane that is + * simply not reattached yet from reading as one whose agent process is gone. */ +export const RESTORED_SUBAGENT_LIVENESS_SWEEP_DELAY_MS = 2 * 60 * 1000 + +export type RestoredSubagentLivenessSweepDeps = { + /** Live local PTY session ids, or null when the inventory could not be read — + * an unavailable listing is not evidence that anything exited. */ + listLiveLocalPtyIds: () => Promise + /** 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: (isLocalPaneAgentLive: (paneKey: string) => boolean) => number +} + +/** Cross-check restored Claude subagent rows against the live local PTY inventory + * and drop the ones no agent process can still be running. Panes launched over + * SSH resolve as live unconditionally: their agent runs on the remote host and + * can never appear here, so scanning for it would prune every live remote row. */ +export async function sweepRestoredSubagentsWithoutLiveAgent( + deps: RestoredSubagentLivenessSweepDeps +): Promise { + const liveIds = await deps.listLiveLocalPtyIds() + if (!liveIds) { + return 0 + } + const livePtyIds = new Set(liveIds) + return deps.reap((paneKey) => { + const ptyId = deps.getBoundPtyIdForPaneKey(paneKey) ?? deps.getPersistedPtyIdForPaneKey(paneKey) + if (!ptyId) { + return false + } + if (parseAppSshPtyId(ptyId)) { + return true + } + return livePtyIds.has(ptyId) + }) +} + +/** Index the persisted terminal layouts as `paneKey -> ptyId`. Layout leaves are + * the only persisted binding that carries a stable pane key, so tab-level PTY ids + * (legacy numeric panes) are deliberately skipped. */ +export function indexPersistedPaneKeyPtyIds( + layoutsByTabId: Record } | undefined> +): Map { + const byPaneKey = new Map() + for (const [tabId, layout] of Object.entries(layoutsByTabId)) { + for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { + if (ptyId) { + byPaneKey.set(`${tabId}:${leafId}`, ptyId) + } + } + } + return byPaneKey +} diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 8887a285235..f2b2a2ad06c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -25,6 +25,7 @@ import { normalizeHookPayload, parseFormEncodedBody, readRequestBody, + reapRestoredClaudeSubagentsForDeadPane, reconcileRemoteCodexState, resolveHookSource, preparePendingGrokResultDiscovery, @@ -35,6 +36,10 @@ import { type AgentHookEventPayload, type HookListenerState } from '../../shared/agent-hook-listener' +import { + claudeRosterHasWorkingSubagent, + claudeRosterToSnapshots +} from '../../shared/claude-subagent-roster' import type { AgentHookSource } from '../../shared/agent-hook-relay' import { CLAUDE_STATUSLINE_PATHNAME, @@ -1916,6 +1921,60 @@ export class AgentHookServer { } } + /** 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. + * + * `isLocalPaneAgentLive` is only consulted for panes whose binding is local + * (`connectionId === null`): a remote/SSH agent runs on the far host and can + * never appear in a local process index, so scanning for it would prune every + * live remote binding. Panes that have produced hook traffic in this runtime are + * skipped as well — their rows are backed by the process that is talking to us. + * Returns the number of panes changed. */ + reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalPaneAgentLive: (paneKey: string) => boolean + ): number { + let changedPanes = 0 + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if ( + enriched.payload.agentType !== 'claude' || + enriched.connectionId !== null || + this.runtimeObservedStatusPaneKeys.has(paneKey) || + isLocalPaneAgentLive(paneKey) + ) { + continue + } + if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { + 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 + this.state.lastStatusByPaneKey.set(paneKey, { + ...enriched, + payload: { ...enriched.payload, state, subagents } + }) + } + if (changedPanes > 0) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + return changedPanes + } + buildPtyEnv(): Record { if (this.port <= 0 || !this.token) { return {} diff --git a/src/main/index.ts b/src/main/index.ts index 9f7e559cb88..ef20d12df0e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,6 +38,11 @@ import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' import { classifyError } from './telemetry/classify-error' import { runManagedHookInstallers } from './agent-hooks/install-telemetry' +import { + indexPersistedPaneKeyPtyIds, + RESTORED_SUBAGENT_LIVENESS_SWEEP_DELAY_MS, + sweepRestoredSubagentsWithoutLiveAgent +} from './agent-hooks/restored-subagent-liveness-sweep' import { isAgentStatusHooksEnabled, MANAGED_AGENT_HOOK_INSTALLERS, @@ -733,6 +738,38 @@ ipcMain.handle( } ) +/** 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. One post-restore pass drops those rows once the live PTY inventory + * proves no agent process is left behind them. */ +function scheduleRestoredSubagentLivenessSweep(): void { + const timer = setTimeout(() => { + const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( + store?.getWorkspaceSession?.()?.terminalLayoutsByTabId ?? {} + ) + void sweepRestoredSubagentsWithoutLiveAgent({ + listLiveLocalPtyIds: async () => { + try { + const sessions = await getLocalPtyProvider().listProcesses() + return sessions.map((session) => session.id) + } catch (err) { + console.warn('[agent-hooks] restored-subagent sweep skipped; PTY listing failed:', err) + return null + } + }, + getBoundPtyIdForPaneKey: getPtyIdForPaneKey, + getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), + reap: (isLocalPaneAgentLive) => + agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent(isLocalPaneAgentLive) + }).catch((err) => { + console.error('[agent-hooks] restored-subagent liveness sweep failed:', err) + }) + }, RESTORED_SUBAGENT_LIVENESS_SWEEP_DELAY_MS) + // Why: recovery bookkeeping must never hold the process open at quit. + timer.unref?.() +} + function startTerminalRuntimeStartupServices(): Promise { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ @@ -778,6 +815,7 @@ function startTerminalRuntimeStartupServices(): Promise { localPtyProviderStartupReady = startupServices.localPtyProviderReady void firstWindowStartupServicesReady.then(() => { logStartupMilestone('first-window-startup-services-ready') + scheduleRestoredSubagentLivenessSweep() }) void localPtyStartupReady.then(() => { logStartupMilestone('local-pty-startup-ready') diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index b1ad81b8907..b2875c925b2 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -554,6 +554,34 @@ describe('agent sleep planner', () => { ).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`]) }) + it('becomes eligible once a phantom subagent stops gating the pane working', () => { + // Why: a restored subagent row holds a finished lead at 'working', which is + // the one state hibernation never accepts — reaping it is what unlocks it. + const gated = entry({ + state: 'working', + subagents: [{ id: 'areview-loop-c237a4c577493352', state: 'working', startedAt: 1 }] + }) + expect(plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [gated.paneKey]: gated } }))).toEqual( + [] + ) + + const reaped = entry({ state: 'done' }) + expect( + plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [reaped.paneKey]: reaped } })) + ).toEqual([`tab-1:${LEAF}`]) + + // Why: reaping only clears the child gate — a draft typed into the composer + // while that segment was open still dies with the PTY, so it keeps blocking. + expect( + plannedPaneKeys( + snapshot({ + agentStatusByPaneKey: { [reaped.paneKey]: reaped }, + lastTerminalInputAtByPaneKey: { [reaped.paneKey]: reaped.stateStartedAt + 1 } + }) + ) + ).toEqual([]) + }) + it('clamps corrupt or out-of-range idle durations to the default', () => { expect(getEffectiveAgentHibernationIdleMs(0)).toBe(DEFAULT_AGENT_HIBERNATION_IDLE_MS) expect(getEffectiveAgentHibernationIdleMs(Number.NaN)).toBe(DEFAULT_AGENT_HIBERNATION_IDLE_MS) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ee3536bb932..2294639df2f 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -33,6 +33,7 @@ import { foldClaudeBackgroundTasksIntoRoster, idleClaudeTeammateByName, readClaudeBackgroundAgentTasks, + reapRestoredClaudeSubagentsWithoutLiveAgent, stopClaudeSubagent, upsertWorkingClaudeSubagent, type ClaudeSubagentRoster @@ -2483,11 +2484,32 @@ export function seedClaudeSubagentRosterFromSnapshots( agentType: snapshot.agentType, 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 + 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. + restoredFromSnapshot: true }) } } +/** 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 || !reapRestoredClaudeSubagentsWithoutLiveAgent(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; without a stash, fall back to 'working' (a transient spinner beats a permanently stuck card). */ function clearClaudePendingWaitForAgent( state: HookListenerState, diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 4c5502309f4..977d96508ed 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -7,6 +7,7 @@ import { foldClaudeBackgroundTasksIntoRoster, idleClaudeTeammateByName, readClaudeBackgroundAgentTasks, + reapRestoredClaudeSubagentsWithoutLiveAgent, stopClaudeSubagent, upsertWorkingClaudeSubagent, type ClaudeSubagentRoster @@ -477,3 +478,51 @@ describe('claude-subagent-roster', () => { expect(claudeRosterToSnapshots(new Map())).toBeUndefined() }) }) + +describe('restored-row liveness reap', () => { + const restored = (id: string): ClaudeSubagentRoster => + new Map([ + [ + id, + { + state: 'working' as const, + startedAt: 100, + backgroundTasksAuthoritative: true, + restoredFromSnapshot: true + } + ] + ]) + + it('drops a restored row when no agent process is left behind it', () => { + const roster = restored('areview-loop-c237a4c577493352') + expect(reapRestoredClaudeSubagentsWithoutLiveAgent(roster)).toBe(true) + expect(claudeRosterHasWorkingSubagent(roster)).toBe(false) + }) + + it('keeps a row a live lifecycle event re-tracked', () => { + const roster = restored('areview-loop-c237a4c577493352') + upsertWorkingClaudeSubagent(roster, 'areview-loop-c237a4c577493352', {}, 150) + expect(reapRestoredClaudeSubagentsWithoutLiveAgent(roster)).toBe(false) + expect(claudeRosterHasWorkingSubagent(roster)).toBe(true) + }) + + it('keeps a row a live inventory confirmed as running', () => { + const roster = restored('a9') + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'a9' })], 150) + expect(reapRestoredClaudeSubagentsWithoutLiveAgent(roster)).toBe(false) + expect(claudeRosterHasWorkingSubagent(roster)).toBe(true) + }) + + it('leaves rows this listener tracked from live events alone', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) + expect(reapRestoredClaudeSubagentsWithoutLiveAgent(roster)).toBe(false) + expect(roster.has('a1')).toBe(true) + }) + + it('leaves the inventory reap of a restored row working', () => { + const roster = restored('aprobe1-6d3cb5b5') + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) + }) +}) diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index 421bddf4cc4..50ee8d2b390 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -36,6 +36,13 @@ export type TrackedClaudeSubagent = { * removes it even when teammate-shaped, so it can't gate the pane * 'working' forever. Cleared once live activity re-tracks the id. */ backgroundTasksAuthoritative?: boolean + /** The row was rebuilt from a persisted snapshot at restore and no live event + * has confirmed it since, so the only thing backing it is a claim written by + * an agent process that may no longer exist. Cleared by any live activity on + * the id. Lets a liveness check reap it when that process is gone — the + * inventory reap alone needs the parent to speak, and an idle parent never + * does. */ + restoredFromSnapshot?: boolean /** A subagent-typed background task listed this lifecycle id id-exact * (workflow/named lanes) — proof the task list tracks this id, so a later * complete list omitting it means finished/killed even though the id is @@ -84,6 +91,9 @@ 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. + existing.restoredFromSnapshot = undefined return } // Why: beyond the wire cap extra rows would be invisible anyway; idle @@ -227,6 +237,9 @@ export function foldClaudeBackgroundTasksIntoRoster( existing.agentType = task.agentType ?? existing.agentType existing.description = task.description ?? existing.description existing.listedAsSubagentTask = true + // Why: a live inventory listed the id as running — the restored claim is + // now confirmed by the current process, so liveness can't reap it. + existing.restoredFromSnapshot = undefined continue } if (!task.running) { @@ -287,6 +300,23 @@ export function foldClaudeBackgroundTasksIntoRoster( } } +/** Second reap path for restored rows, used when the agent process that wrote + * the snapshot is gone. The inventory reap needs the parent to emit a complete + * `background_tasks` list; a parent that went idle before Orca restarted never + * emits one, so an unconfirmed row would gate the pane 'working' forever and + * keep it out of hibernation. Rows confirmed by live activity are untouched. + * Returns whether anything was dropped. */ +export function reapRestoredClaudeSubagentsWithoutLiveAgent(roster: ClaudeSubagentRoster): boolean { + let changed = false + for (const [id, tracked] of roster) { + if (tracked.restoredFromSnapshot === true) { + roster.delete(id) + changed = true + } + } + return changed +} + /** Whether a lifecycle agent id belongs to the named teammate. Teammate ids * embed the name as `a-`; requiring a hyphen-free suffix keeps * teammate "rev" from matching "rev-two"'s ids (`arev-two-`), while a