From 9608cb74fdad1c76ba4bef6cc0d36bfb890975c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:15:38 -0700 Subject: [PATCH] fix(agent-status): persist Claude idle-row hydration cleanup --- src/main/agent-hooks/server.test.ts | 4 +++ src/main/agent-hooks/server.ts | 30 ++++++++++++----------- src/shared/agent-hook-listener.ts | 8 +++--- src/shared/claude-subagent-roster.test.ts | 2 +- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 4296d5ce081..1239e6dd9eb 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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() } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index d3bca6f60cf..4011fdabda6 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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), 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() } } diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 82bb5485a12..bde6baf4431 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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, diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 71dc28fe979..937534709b3 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -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')