fix(agent-status): stop reconnect replay re-arming a stale working row (STA-4144)

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.
This commit is contained in:
Brennan Benson
2026-08-14 14:39:10 -07:00
parent 9bb8836bb6
commit 4afc2323b7
2 changed files with 125 additions and 1 deletions
@@ -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)
})
})
+10 -1
View File
@@ -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)