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