fix(hibernation): reap restored subagent rows with no live agent process

A pane whose Claude session had a subagent in flight can be locked out of
agent hibernation for good. A PTY that dies while Orca is down never runs
the teardown that clears pane state, so hydrate rebuilds a subagent roster
that nothing can retire: the existing reap needs the parent to emit a
complete `background_tasks` inventory, and a parent that went idle before
the restart never emits one. The restored row keeps gating the pane
'working', and hibernation only accepts 'done'.

Observed locally: six panes parked at SubagentStop in state 'working' for
17 to 145 hours, each still holding a working child row.

Adds a second reap path. Hydrate seeds are marked `restoredFromSnapshot`,
cleared by any live lifecycle event or an id-exact running inventory entry.
One post-restore sweep drops the rows still unconfirmed when the pane's PTY
is absent from the live local inventory, then re-derives the child-gated
'working' to 'done'.

The scan is local-only by construction: panes with a relay connection id are
skipped and SSH-scoped PTY ids resolve as live, since a remote agent runs on
the far host and could never appear in a local listing. An unreadable
inventory is not evidence that anything exited, so it is a no-op. Panes that
have reported to this runtime are left alone.

`stateStartedAt` and `stateHistory` are untouched, so a draft typed while
the pane was working still blocks hibernation.
This commit is contained in:
Brennan Benson
2026-07-28 13:38:11 -07:00
parent 0388319a32
commit b0d76398b6
8 changed files with 524 additions and 1 deletions
@@ -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<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: '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<string, string>
persistedPtyIdByPaneKey?: Record<string, string>
} = {}
): Promise<number> {
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]]))
})
})
@@ -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<readonly string[] | null>
/** 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<number> {
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<string, { ptyIdsByLeafId?: Record<string, string> } | undefined>
): Map<string, string> {
const byPaneKey = new Map<string, string>()
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
}
+59
View File
@@ -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<string, string> {
if (this.port <= 0 || !this.token) {
return {}
+38
View File
@@ -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<void> {
logStartupMilestone('first-window-startup-services-start')
const startupServices = startFirstWindowStartupServices({
@@ -778,6 +815,7 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
localPtyProviderStartupReady = startupServices.localPtyProviderReady
void firstWindowStartupServicesReady.then(() => {
logStartupMilestone('first-window-startup-services-ready')
scheduleRestoredSubagentLivenessSweep()
})
void localPtyStartupReady.then(() => {
logStartupMilestone('local-pty-startup-ready')
@@ -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)
+23 -1
View File
@@ -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,
+49
View File
@@ -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)
})
})
+30
View File
@@ -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<name>-<hex>`; requiring a hyphen-free suffix keeps
* teammate "rev" from matching "rev-two"'s ids (`arev-two-<hex>`), while a