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

* 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.

* fix(hibernation): prove local ownership before restored reap

* fix(hibernation): require authoritative restored PTY absence

* fix(hibernation): probe restored PTY liveness authoritatively

* fix(hibernation): restart idle window after restored reap

* fix(hibernation): type restored reconciliation timing

* fix(hibernation): respect worktree host ownership

* fix(hibernation): preserve same-id restored PTY rebinds

* fix(hibernation): fence batched restored PTY probes
This commit is contained in:
Brennan Benson
2026-07-29 16:30:44 -07:00
committed by GitHub
parent f56c37b470
commit 24a2accc3c
18 changed files with 1132 additions and 17 deletions
+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)
})
})
+44
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,37 @@ 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
}
export function claudeRosterHasRestoredSnapshotSubagent(
roster: ClaudeSubagentRoster | undefined
): boolean {
if (!roster) {
return false
}
for (const tracked of roster.values()) {
if (tracked.restoredFromSnapshot === true) {
return true
}
}
return false
}
/** 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