From 4afc2323b7e79fad8a672d5f4f0e4951bfa01b2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:39:10 -0700 Subject: [PATCH] fix(agent-status): stop reconnect replay re-arming a stale working row (STA-4144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reconnect replay re-delivers a status this client already holds, but applyNormalizedStatus stamped it receivedAt = now while attachStatusTiming kept the cached stateStartedAt. A pane whose Stop was lost (sleep, app restart, relay drop) therefore came back "working" with a fresh freshness clock and an hours-old displayed age, and every reconnect re-armed it — so the 30-minute stale-decay safety net could never fire. Preserve the cached receivedAt when a replay carries the same state. A replay carrying a NEW state is real news (it can be the completion this client missed) and keeps live timing. Refs STA-3532, STA-3522 (duplicates), STA-3524. --- .../reconnect-replay-status-freshness.test.ts | 115 ++++++++++++++++++ src/main/agent-hooks/server.ts | 11 +- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/main/agent-hooks/reconnect-replay-status-freshness.test.ts diff --git a/src/main/agent-hooks/reconnect-replay-status-freshness.test.ts b/src/main/agent-hooks/reconnect-replay-status-freshness.test.ts new file mode 100644 index 00000000000..be5db59f9de --- /dev/null +++ b/src/main/agent-hooks/reconnect-replay-status-freshness.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { + AGENT_STATUS_STALE_AFTER_MS, + isFreshNonDoneAgentStatus +} from '../../shared/agent-status-types' +import { makePaneKey } from '../../shared/stable-pane-id' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey('tab-1', LEAF_1) +const T0 = 1_700_000_000_000 + +const workingEvent = { + paneKey: PANE, + hookEventName: 'PreToolUse', + payload: { + state: 'working' as const, + prompt: 'fix the typecheck failures', + agentType: 'claude' as const, + toolName: 'Bash', + toolInput: 'pnpm run typecheck' + } +} + +/** The sidebar reads the IPC row; mirror its freshness gate on the real snapshot. */ +function paneRow(server: AgentHookServer): { + state: string + updatedAt: number + stateStartedAt: number +} { + const row = server.getStatusSnapshot()[0] + if (!row) { + throw new Error('expected a cached status row') + } + return { + state: row.state, + updatedAt: row.receivedAt, + stateStartedAt: row.stateStartedAt + } +} + +function isRenderedAsWorking(server: AgentHookServer, now: number): boolean { + const row = paneRow(server) + return isFreshNonDoneAgentStatus({ state: row.state as 'working', updatedAt: row.updatedAt }, now) +} + +describe('reconnect replay does not re-arm agent status freshness (STA-4144)', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(T0) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps a replayed unchanged working row stale so it decays to idle', () => { + const server = new AgentHookServer() + server.ingestRemote(workingEvent, 'conn-1') + expect(isRenderedAsWorking(server, Date.now())).toBe(true) + + // The turn ends while the relay is disconnected (sleep / restart), so the + // matching Stop never reaches this client and the row freezes at working. + const afterStaleWindow = T0 + AGENT_STATUS_STALE_AFTER_MS + 60_000 + vi.setSystemTime(afterStaleWindow) + expect(isRenderedAsWorking(server, afterStaleWindow)).toBe(false) + + // Reconnect: the host replays its cached statuses one frame per pane. + server.ingestRemote({ ...workingEvent, isReplay: true }, 'conn-1') + + const row = paneRow(server) + // Regression: the replay must not look newer than the evidence behind it. + expect(row.updatedAt).toBe(T0) + expect(row.stateStartedAt).toBe(T0) + expect(isRenderedAsWorking(server, afterStaleWindow)).toBe(false) + }) + + it('still lets a replay deliver a completion this client never saw', () => { + const server = new AgentHookServer() + server.ingestRemote(workingEvent, 'conn-1') + + const later = T0 + AGENT_STATUS_STALE_AFTER_MS + 60_000 + vi.setSystemTime(later) + server.ingestRemote( + { + ...workingEvent, + isReplay: true, + payload: { ...workingEvent.payload, state: 'done' as const } + }, + 'conn-1' + ) + + const row = paneRow(server) + expect(row.state).toBe('done') + expect(row.updatedAt).toBe(later) + }) + + it('still refreshes freshness for a live same-state working ping', () => { + const server = new AgentHookServer() + server.ingestRemote(workingEvent, 'conn-1') + + const later = T0 + 20 * 60_000 + vi.setSystemTime(later) + server.ingestRemote(workingEvent, 'conn-1') + + const row = paneRow(server) + expect(row.updatedAt).toBe(later) + // A genuinely active agent stays working past the original stale deadline. + expect(isRenderedAsWorking(server, T0 + AGENT_STATUS_STALE_AFTER_MS + 60_000)).toBe(true) + // The displayed age still tracks when the state began, not the last ping. + expect(row.stateStartedAt).toBe(T0) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f819414ad4b..2b09acc10d1 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1276,7 +1276,16 @@ export class AgentHookServer { this.maybeTrackAgentPromptSent(effectivePayload, previous) } const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload) - const enriched = this.attachStatusTiming(cachedPayload, now) + // Why: a reconnect replay re-delivers evidence we already hold, so stamping it + // `now` re-arms the 30-minute staleness clock while attachStatusTiming keeps the + // old stateStartedAt — a finished pane then spins forever showing an hours-old + // age, re-armed by every reconnect (STA-4144). A replay carrying a NEW state is + // real news (it may be the completion this client missed) and keeps live timing. + const replayPreservedReceivedAt = + cachedPayload.isReplay === true && previous?.payload.state === cachedPayload.payload.state + ? previous?.receivedAt + : undefined + const enriched = this.attachStatusTiming(cachedPayload, replayPreservedReceivedAt ?? now) // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. if (enriched.restoredUnconfirmed) { this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)