mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user