fix(agent-status): persist Claude idle-row hydration cleanup

This commit is contained in:
Brennan Benson
2026-07-15 01:15:38 -07:00
parent a211b8c787
commit 9608cb74fd
4 changed files with 24 additions and 20 deletions
+4
View File
@@ -6093,6 +6093,10 @@ describe('Last-status persistence', () => {
subagents: undefined
})
])
// Why: make the migration one-time; otherwise every launch reparses and
// re-prunes the same persisted idle rows.
const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(persisted.entries[PANE].payload.subagents).toBeUndefined()
} finally {
server.stop()
}
+16 -14
View File
@@ -244,11 +244,10 @@ function sanitizeHydratedEntry(
} else {
return null
}
const normalizedPayload = normalizeAgentStatusPayload(record.payload)
if (!normalizedPayload) {
const payload = normalizeAgentStatusPayload(record.payload)
if (!payload) {
return null
}
const payload = dropHydratedIdleClaudeSubagents(normalizedPayload)
return {
paneKey,
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
@@ -1777,6 +1776,7 @@ export class AgentHookServer {
}
let hydrated = 0
let dropped = 0
let prunedLegacyClaudeSubagents = 0
// Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so
// entries from worktrees archived weeks ago do not pile up forever. Use
// Date.now() once to keep the cutoff consistent across all entries this
@@ -1790,11 +1790,15 @@ export class AgentHookServer {
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
if (entry && entry.receivedAt >= ttlCutoff) {
const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload)
if (hydratedPayload !== entry.payload) {
prunedLegacyClaudeSubagents +=
(entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0)
entry.payload = hydratedPayload
}
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
// Why: the in-memory subagent roster died with the previous process.
// Reseed it from the persisted snapshot so the next teammate-bearing
// Stop (whose task ids never match lifecycle ids) doesn't silently
// drop the replayed child rows.
// Why: preserve only working children across restart. Live activity
// confirms them; a later complete inventory may reap stale seeds.
if (entry.payload.subagents) {
seedClaudeSubagentRosterFromSnapshots(
this.state,
@@ -1812,18 +1816,16 @@ export class AgentHookServer {
`[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})`
)
}
if (hydrated > 0 && dropped === 0) {
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
// Why: persist load-time pruning once so legacy idle rows do not consume
// parse/filter work again on every launch.
this.runStatusPersist()
} else if (hydrated > 0) {
// Why: prime from the raw on-disk bytes (not a re-serialization) so the
// dedup is robust against future shape drift in serializeStatusFile.
// Only prime when hydration was lossless — if entries were dropped
// during sanitize, the in-memory map diverges from the on-disk bytes.
this.lastWrittenJson = raw
} else if (dropped > 0) {
// Why: clean the stale on-disk file now so a user who has not run an
// agent in 8+ days does not re-drop the same entries on every cold
// boot. Synchronous variant is safe at start time and avoids
// unref'd-timer-during-quit edge cases.
this.runStatusPersist()
}
}
+3 -5
View File
@@ -2477,11 +2477,9 @@ export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey:
state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true })
}
/** Rebuild a pane's roster from a persisted status snapshot during hydration.
* Restart loses the in-memory roster while the renderer replays the child
* rows from disk; without reseeding, the next teammate-bearing Stop (whose
* task ids never match lifecycle ids) would silently drop those rows. Stale
* seeds self-heal: an empty background_tasks clears, activity refreshes. */
/** Rebuild a pane's working roster from a persisted status snapshot. Live
* activity confirms a seed after restart; a complete task inventory may reap
* an unconfirmed seed whose finish hook arrived while Orca was offline. */
export function seedClaudeSubagentRosterFromSnapshots(
state: HookListenerState,
paneKey: string,
+1 -1
View File
@@ -46,7 +46,7 @@ describe('claude-subagent-roster', () => {
expect(roster.has('aprobe1-6d3cb5b5')).toBe(false)
})
it('re-adds a resumed agent as working, keeping its original startedAt', () => {
it('re-adds a resumed agent as working with a fresh startedAt', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100)
finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5')