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
@@ -554,6 +554,41 @@ describe('agent sleep planner', () => {
).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`])
})
it('restarts the idle window 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', updatedAt: NOW, stateStartedAt: NOW })
expect(
plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [reaped.paneKey]: reaped } }))
).toEqual([])
const idleReaped = entry({ state: 'done' })
expect(
plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [idleReaped.paneKey]: idleReaped } }))
).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: { [idleReaped.paneKey]: idleReaped },
lastTerminalInputAtByPaneKey: {
[idleReaped.paneKey]: idleReaped.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)