fix(remote): stop unlabelled inventories and replayed rows authorising destruction (#17981)

* fix(remote): stop one unlabelled inventory tombstoning a live worktree mirror

#11495 Step C. `buildMissingWebSessionTabsRemovals` synthesised a `removed: true`
tombstone -- emptying a worktree's entire mirror -- for any tracked worktree
absent from a single inventory frame, without ever consulting the host's own
authority label. `mirror-settle` already refuses to settle an *empty* inventory
that is not `authoritative` (#16414, #16546); the strictly more destructive
action was ungated.

An inventory the host labels `authoritative` carries a complete PTY census, so
one omission is host attestation and removal stays immediate. An unlabelled
inventory is a degraded or version-skewed census: `unverifiable`, not `exited`.
It must now repeat before it can destroy anything, reusing the two-observation
shape of `confirmSurfaceInventoryAbsence`. A legacy host that never negotiates
the capability still converges after two rounds, so ghost rows cannot outlive
the fence.

The 14 tests from #13621 that blocked this were all written before the
`authoritative` label existed (#13621 landed 2026-08-11; the capability landed
2026-08-26 in #16546). #13621's own summary says "Reconcile each resumed host
from an authoritative inventory, including removals", so their fixtures are
retargeted to say so explicitly rather than weakened.

Refs #11495

* fix(agent-status): stop a reconnect replay restamping the staleness clock

#15317 correctness half. `receivedAt` was doing two jobs: delivery order and
evidence age. A relay reconnect replays every cached row, and `receivedAt` must
restamp to clear the connection watermark that `clearStatusEntriesForConnection`
raises -- so a pane stuck at `working` had its 30-minute deadline pushed out by
another 30 minutes on every reconnect. The TTL was never reached, which is why
this read as a tuning question.

Two clocks, not one rewritten clock:

- `receivedAt` is untouched. The transient-clear watermark and the four `<`
  ordering drops (`agent-status-event-applicator`, `agent-status-live-entry-builder`,
  `agent-status-cleanup-actions`) keep working unchanged. Restamping a replay with
  its original time would have made it `<= watermark` and dropped it outright,
  leaving the pane with no row at all.
- `evidenceObservedAt` is new, optional, and read only by the staleness
  comparison (`isFreshNonDoneAgentStatus`, `isExplicitAgentStatusFresh`, the
  freshness scheduler). Main holds it per pane across the transport clear -- the
  clear deletes the row on purpose, but the *age* of evidence a later replay
  restates is not a claim about the pane. Absent means "no separate observation",
  and every consumer falls back to `receivedAt`/`updatedAt`, so old hosts and old
  rows behave exactly as today.

Behaviour: a genuinely active pane keeps stamping the observation clock from its
real events, so it stays `working` across a reconnect. A pane whose relay
restarted replays nothing and still falls through to title evidence. A torn-down
pane drops its remembered clock in `clearPaneState`, so a reused pane key cannot
inherit one.

`AGENT_STATUS_STALE_AFTER_MS` is deliberately unchanged -- the window length
remains a product decision.

Refs #15317
This commit is contained in:
Neil
2026-09-01 22:43:38 -07:00
committed by GitHub
parent 7ba832c7ba
commit 4e35e058fc
25 changed files with 520 additions and 174 deletions
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import type { EnrichedAgentHookEventPayload } from './server/server-types'
import { buildBody, PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
const CONNECTION = 'conn-1'
const T0 = 1_800_000_000_000
function ingest(
server: AgentHookServer,
payload: Record<string, unknown>,
options: { isReplay?: boolean } = {}
): void {
const event = normalizeHookPayload(
createHookListenerState(),
'claude',
buildBody(payload),
'production'
)
if (!event) {
throw new Error('normalizeHookPayload rejected a known-good Claude fixture')
}
server.ingestRemote({ ...event, ...(options.isReplay ? { isReplay: true } : {}) }, CONNECTION)
}
describe('the observation clock a relay replay must not restamp', () => {
let server: AgentHookServer
let emitted: EnrichedAgentHookEventPayload[]
beforeEach(() => {
_internals.resetCachesForTests()
vi.useFakeTimers()
vi.setSystemTime(T0)
server = new AgentHookServer()
emitted = []
server.setListener((payload) => {
emitted.push(payload)
})
})
afterEach(() => {
server.setListener(null)
vi.useRealTimers()
vi.restoreAllMocks()
})
const lastForPane = (): EnrichedAgentHookEventPayload =>
emitted.toReversed().find((event) => event.paneKey === PANE)!
it('holds the observation time across a reconnect replay while delivery order advances', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
expect(lastForPane().evidenceObservedAt).toBe(T0)
vi.setSystemTime(T0 + 25 * 60 * 1000)
// A lost transport clears the row; the age of the evidence it restates is not a claim.
server.clearStatusEntriesForConnection(CONNECTION)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
{ isReplay: true }
)
const replayed = lastForPane()
expect(replayed.payload.state).toBe('working')
// Delivery order must still clear the connection watermark, or the renderer drops the row.
expect(replayed.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1)
expect(replayed.evidenceObservedAt).toBe(T0)
})
it('lets a live event restamp the observation time after a replay', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
vi.setSystemTime(T0 + 25 * 60 * 1000)
server.clearStatusEntriesForConnection(CONNECTION)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
{ isReplay: true }
)
vi.setSystemTime(T0 + 26 * 60 * 1000)
ingest(server, { hook_event_name: 'PreToolUse', tool_name: 'Edit' })
expect(lastForPane().evidenceObservedAt).toBe(T0 + 26 * 60 * 1000)
})
it('gives a torn-down pane no inherited observation time', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
server.clearPaneState(PANE)
vi.setSystemTime(T0 + 25 * 60 * 1000)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'a new session' },
{ isReplay: true }
)
expect(lastForPane().evidenceObservedAt).toBe(T0 + 25 * 60 * 1000)
})
})
@@ -97,6 +97,10 @@ export abstract class AgentHookServerState {
protected closedAgentStatusPaneKeys = new Set<string>()
protected restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
protected connectionTimestampWatermarkById = new Map<string, number>()
// Why: survives the row itself. A transport clear deletes the pane's status row on purpose
// (absence, not completion), but the *age* of the evidence a later replay restates is not a
// claim about the pane and must not be lost with it. Bounded like its sibling maps.
protected evidenceObservedAtByPaneKey = new Map<string, number>()
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
protected lastWrittenJson: string | null = null
// Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own
@@ -13,6 +13,9 @@ import type { EnrichedAgentHookEventPayload } from './server-types'
import { agentTypeToPromptSentAgentKind } from './server-status-identity'
import { AgentHookServerStatusDisposition } from './server-status-disposition'
/** Bounds the retained observation clock; eviction only degrades a replay to `now`. */
const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024
export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition {
protected attachStatusTiming(
payload: AgentHookEventPayload,
@@ -41,10 +44,38 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
return {
...payload,
receivedAt: now,
evidenceObservedAt: this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt
}
}
/**
* A replay restates evidence already observed; it is not a new observation. Keeping
* `receivedAt` at `now` preserves delivery order (the connection-clear watermark and the
* renderer's four `<` drops all depend on it), while this clock records when the evidence
* was actually seen — so the staleness window measures age, not reconnect count.
* Without a remembered time the honest answer is `now`, which is today's behaviour.
*/
private resolveEvidenceObservedAt(
payload: AgentHookEventPayload,
previous: EnrichedAgentHookEventPayload | undefined,
now: number
): number {
const remembered =
previous?.evidenceObservedAt ?? this.evidenceObservedAtByPaneKey.get(payload.paneKey)
const observedAt = payload.isReplay === true && remembered !== undefined ? remembered : now
this.evidenceObservedAtByPaneKey.delete(payload.paneKey)
this.evidenceObservedAtByPaneKey.set(payload.paneKey, observedAt)
while (this.evidenceObservedAtByPaneKey.size > MAX_REMEMBERED_EVIDENCE_OBSERVATIONS) {
const oldest = this.evidenceObservedAtByPaneKey.keys().next().value
if (typeof oldest !== 'string') {
break
}
this.evidenceObservedAtByPaneKey.delete(oldest)
}
return observedAt
}
protected hashPromptForTelemetryDedupe(prompt: string): string {
return createHash('sha256')
.update(this.promptSentHashSalt)
@@ -94,6 +94,8 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.currentAuthorityObservations.delete(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
// Why: the pane itself is gone, so its observation clock describes nothing a later pane owns.
this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) {
if (alias.stablePaneKey === resolvedPaneKey) {
@@ -105,6 +107,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
this.evidenceObservedAtByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
@@ -11,6 +11,11 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-ty
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
/** When this evidence was first observed, as distinct from `receivedAt`. A relay reconnect
* replays cached rows and `receivedAt` must restamp to clear the connection watermark, so
* only this clock can answer how old the evidence itself is. Persisted so it survives a
* main restart; absent means "never separately observed" and consumers use `receivedAt`. */
evidenceObservedAt?: number
stateStartedAt: number
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
observation?: AgentStatusObservation
@@ -35,6 +35,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
connectionId,
payload,
receivedAt,
evidenceObservedAt,
stateStartedAt,
launchToken,
providerSession,
@@ -57,6 +58,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
worktreeId,
connectionId,
receivedAt,
...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}),
stateStartedAt,
...(providerSession ? { providerSession } : {}),
...(observation ? { observation } : {}),
@@ -88,6 +90,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
worktreeId,
connectionId,
receivedAt,
...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}),
stateStartedAt,
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
@@ -225,6 +225,9 @@ export function createAgentStatusEventApplicator(args: {
terminalTitle,
timing: {
updatedAt: data.receivedAt,
...(data.evidenceObservedAt !== undefined
? { evidenceObservedAt: data.evidenceObservedAt }
: {}),
stateStartedAt: data.stateStartedAt
},
routing: {
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus,
type AgentStatusEntry
} from '../../../shared/agent-status-types'
import { isExplicitAgentStatusFresh } from './pane-agent-evidence'
const NOW = new Date('2026-04-09T12:00:00.000Z').getTime()
const OBSERVED_AT = NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000
function workingRow(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
state: 'working',
prompt: 'do the thing',
// A reconnect replay restamps the delivery clock; that must not read as new evidence.
updatedAt: NOW,
stateStartedAt: OBSERVED_AT,
stateHistory: [],
agentType: 'claude',
...overrides
}
}
describe('staleness measures when evidence was observed, not when it was delivered', () => {
it('decays a replayed row whose evidence is older than the window', () => {
const row = workingRow({ evidenceObservedAt: OBSERVED_AT })
expect(isFreshNonDoneAgentStatus(row, NOW)).toBe(false)
expect(isExplicitAgentStatusFresh(row, NOW, AGENT_STATUS_STALE_AFTER_MS)).toBe(false)
})
it('falls back to the delivery clock for a row from a host that sends no observation time', () => {
const row = workingRow()
expect(isFreshNonDoneAgentStatus(row, NOW)).toBe(true)
expect(isExplicitAgentStatusFresh(row, NOW, AGENT_STATUS_STALE_AFTER_MS)).toBe(true)
})
it('keeps a live row fresh when its evidence was just observed', () => {
const row = workingRow({ evidenceObservedAt: NOW })
expect(isFreshNonDoneAgentStatus(row, NOW)).toBe(true)
expect(isExplicitAgentStatusFresh(row, NOW, AGENT_STATUS_STALE_AFTER_MS)).toBe(true)
})
})
+5 -2
View File
@@ -4,6 +4,7 @@ import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-
import type { TuiAgent } from '../../../shared/tui-agent'
import {
AGENT_STATUS_STALE_AFTER_MS,
agentStatusEvidenceObservedAt,
type AgentStatusEntry,
type AgentStatusState,
type AgentType
@@ -15,12 +16,14 @@ import {
// (Moved here from agent-status.ts so the evidence resolvers below and the
// aggregate consumers share one gate without an import cycle.)
export function isExplicitAgentStatusFresh(
entry: Pick<AgentStatusEntry, 'updatedAt' | 'restoredUnconfirmed'>,
entry: Pick<AgentStatusEntry, 'updatedAt' | 'evidenceObservedAt' | 'restoredUnconfirmed'>,
now: number,
staleAfterMs: number
): boolean {
// Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh.
return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= staleAfterMs
return (
entry.restoredUnconfirmed !== true && now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs
)
}
/**
@@ -218,7 +218,8 @@ describe('a deferred visibility-resume repair patch', () => {
type: 'snapshots',
snapshots: [
{ ...makeHostSnapshot(WT, HOST_SURFACE_ID, HOST_PARENT_TAB_ID), snapshotVersion: 2 }
]
],
authoritative: true
})
// The tombstone repair DID reach the store: the background mirror retracted,
@@ -315,7 +315,8 @@ describe('useWebSessionTabsSync visibility collision recovery', () => {
})
await publish(findGlobalSubscription(ENV_A, 1), {
type: 'snapshots',
snapshots: []
snapshots: [],
authoritative: true
})
const state = useAppStore.getState()
@@ -386,7 +387,8 @@ describe('useWebSessionTabsSync visibility collision recovery', () => {
})
await publish(findGlobalSubscription(ENV_A, 1), {
type: 'snapshots',
snapshots: []
snapshots: [],
authoritative: true
})
const state = useAppStore.getState()
@@ -432,7 +434,8 @@ describe('useWebSessionTabsSync visibility collision recovery', () => {
})
await publish(findGlobalSubscription(ENV_A, 1), {
type: 'snapshots',
snapshots: []
snapshots: [],
authoritative: true
})
const tabId = toWebTerminalSurfaceTabId('host-tab-b')
expect(useAppStore.getState().tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual([tabId])
@@ -480,7 +483,8 @@ describe('useWebSessionTabsSync visibility collision recovery', () => {
}
await publish(findGlobalSubscription(ENV_A, 1), {
type: 'snapshots',
snapshots: [unrelatedSnapshot]
snapshots: [unrelatedSnapshot],
authoritative: true
})
const hostBTabId = toWebTerminalSurfaceTabId('host-tab-b')
expect(useAppStore.getState().tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual([
@@ -539,7 +543,8 @@ describe('useWebSessionTabsSync visibility collision recovery', () => {
await publish(findGlobalSubscription(ENV_B, 1), {
type: 'snapshots',
snapshots: []
snapshots: [],
authoritative: true
})
expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(1)
expect(useAppStore.getState().tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual([
@@ -575,7 +580,8 @@ describe('useWebSessionTabsSync visibility collision recovery', () => {
})
await publish(findGlobalSubscription(ENV_A, 1), {
type: 'snapshots',
snapshots: []
snapshots: [],
authoritative: true
})
slowInventory.resolve(makeTerminalSnapshot('-b'))
@@ -612,7 +618,8 @@ describe('useWebSessionTabsSync visibility collision recovery', () => {
})
await publish(findGlobalSubscription(ENV_A, 1), {
type: 'snapshots',
snapshots: []
snapshots: [],
authoritative: true
})
expect(useAppStore.getState().tabsByWorktree[WORKTREE]).toBeUndefined()
@@ -214,6 +214,15 @@ function seedRemoteMirrorState(): void {
)
}
/** A host that negotiated `session-tabs.authoritative-inventory.v1` labels a complete census. */
function authoritativeInventory(snapshots: RuntimeMobileSessionTabsResult[]): {
type: 'snapshots'
snapshots: RuntimeMobileSessionTabsResult[]
authoritative: true
} {
return { type: 'snapshots', snapshots, authoritative: true }
}
describe('useWebSessionTabsSync window visibility', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -245,6 +254,15 @@ describe('useWebSessionTabsSync window visibility', () => {
vi.useRealTimers()
})
const parkAndReveal = async (parkMultiplier = 1): Promise<void> => {
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * parkMultiplier)
setDocumentVisibility('visible')
})
await act(settle)
}
it('parks every live mirror without repeating one-shot hydration', async () => {
const hook = renderHook(() => useWebSessionTabsSync())
await act(settle)
@@ -297,12 +315,7 @@ describe('useWebSessionTabsSync window visibility', () => {
const browserTabsByWorktree = useAppStore.getState().browserTabsByWorktree
mocks.recoverSnapshot.mockClear()
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await parkAndReveal()
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: [snapshot]
@@ -334,12 +347,7 @@ describe('useWebSessionTabsSync window visibility', () => {
acceptReplayedWebSessionTabsSnapshot(ENV_A, WORKTREE)
act(() => useAppStore.setState({ browserTabsByWorktree: {} }))
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await parkAndReveal()
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: [snapshot]
@@ -446,17 +454,60 @@ describe('useWebSessionTabsSync window visibility', () => {
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toHaveLength(1)
expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(1)
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await parkAndReveal()
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([])
)
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined()
expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(0)
hook.unmount()
})
it('retains an omitted mirror when the host does not label the inventory authoritative', async () => {
const hook = renderHook(() => useWebSessionTabsSync())
await act(settle)
await publish(findSubscription('session.tabs.subscribeAll', ENV_A), {
type: 'snapshots',
snapshots: [makeBrowserSnapshot()]
})
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toHaveLength(1)
await parkAndReveal()
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
// A census the host declines to call authoritative is unverifiable, not proof the worktree is gone.
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toHaveLength(1)
expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(1)
hook.unmount()
})
it('removes an omitted mirror once two unlabelled inventories agree', async () => {
const hook = renderHook(() => useWebSessionTabsSync())
await act(settle)
await publish(findSubscription('session.tabs.subscribeAll', ENV_A), {
type: 'snapshots',
snapshots: [makeBrowserSnapshot()]
})
await parkAndReveal()
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toHaveLength(1)
await parkAndReveal(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT)
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 2), {
type: 'snapshots',
snapshots: []
})
// A legacy host that never sends the label still converges, so ghosts cannot outlive two rounds.
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined()
expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(0)
hook.unmount()
@@ -482,10 +533,10 @@ describe('useWebSessionTabsSync window visibility', () => {
type: 'snapshots',
snapshots: [makeBrowserSnapshot('-b')]
})
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([])
)
const handles = Object.values(useAppStore.getState().remoteBrowserPageHandlesByPageId)
expect(handles.some((handle) => handle.environmentId === ENV_A)).toBe(false)
@@ -519,16 +570,11 @@ describe('useWebSessionTabsSync window visibility', () => {
)
).toBe(true)
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
await parkAndReveal()
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([])
)
expect(
Object.values(useAppStore.getState().remoteBrowserPageHandlesByPageId).some(
@@ -547,12 +593,7 @@ describe('useWebSessionTabsSync window visibility', () => {
snapshots: [snapshot]
})
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await parkAndReveal()
const slowOtherSnapshot = {
...makeEmptySnapshot(),
worktree: 'repo-a::other-worktree'
@@ -590,22 +631,17 @@ describe('useWebSessionTabsSync window visibility', () => {
snapshots: [originalSnapshot]
})
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await parkAndReveal()
const newerSnapshot = { ...makeBrowserSnapshot('-new'), snapshotVersion: 2 }
await publish(findSubscription('session.tabs.subscribe', ENV_A, 1), {
type: 'snapshot',
...newerSnapshot
})
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([])
)
expect(useAppStore.getState().activeBrowserTabIdByWorktree[WORKTREE]).toBe(
'host-browser-workspace-new'
@@ -637,10 +673,10 @@ describe('useWebSessionTabsSync window visibility', () => {
act(() => setDocumentVisibility('visible'))
await act(settle)
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([])
)
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined()
expect(
@@ -694,12 +730,7 @@ describe('useWebSessionTabsSync window visibility', () => {
snapshots: [makeBrowserSnapshot('-old')]
})
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await parkAndReveal()
const unrelatedSnapshot = {
...makeEmptySnapshot(),
@@ -708,10 +739,7 @@ describe('useWebSessionTabsSync window visibility', () => {
const olderInventoryRecovery = createDeferred<RuntimeMobileSessionTabsResult>()
mocks.recoverSnapshot.mockImplementationOnce(() => olderInventoryRecovery.promise)
const resumedGlobal = findSubscription('session.tabs.subscribeAll', ENV_A, 1)
await publish(resumedGlobal, {
type: 'snapshots',
snapshots: [unrelatedSnapshot]
})
await publish(resumedGlobal, authoritativeInventory([unrelatedSnapshot]))
const newerSnapshot = { ...makeBrowserSnapshot('-new'), snapshotVersion: 2 }
await publish(resumedGlobal, {
type: 'snapshots',
@@ -753,10 +781,10 @@ describe('useWebSessionTabsSync window visibility', () => {
}
const slowInventoryRecovery = createDeferred<RuntimeMobileSessionTabsResult>()
mocks.recoverSnapshot.mockImplementationOnce(() => slowInventoryRecovery.promise)
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: [unrelatedSnapshot]
})
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([unrelatedSnapshot])
)
await publish(findSubscription('session.tabs.subscribe', ENV_A, 1), {
type: 'snapshot',
...snapshot,
@@ -779,16 +807,11 @@ describe('useWebSessionTabsSync window visibility', () => {
snapshots: [snapshot]
})
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
await parkAndReveal()
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([])
)
await publish(findSubscription('session.tabs.subscribe', ENV_A, 1), {
type: 'snapshot',
...snapshot
@@ -823,17 +846,6 @@ describe('useWebSessionTabsSync window visibility', () => {
})
it('drops an omission fence one generation after the inventory that set it', async () => {
const parkAndReveal = async (): Promise<void> => {
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(
WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS *
WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT
)
setDocumentVisibility('visible')
})
await act(settle)
}
const hook = renderHook(() => useWebSessionTabsSync())
await act(settle)
const snapshot = { ...makeBrowserSnapshot(), snapshotVersion: 2 }
@@ -842,21 +854,21 @@ describe('useWebSessionTabsSync window visibility', () => {
snapshots: [snapshot]
})
await parkAndReveal()
await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), {
type: 'snapshots',
snapshots: []
})
await parkAndReveal(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT)
await publish(
findSubscription('session.tabs.subscribeAll', ENV_A, 1),
authoritativeInventory([])
)
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined()
await parkAndReveal()
await parkAndReveal(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT)
await publish(findSubscription('session.tabs.subscribe', ENV_A, 2), {
type: 'snapshot',
...snapshot
})
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined()
await parkAndReveal()
await parkAndReveal(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT)
await publish(findSubscription('session.tabs.subscribe', ENV_A, 3), {
type: 'snapshot',
...snapshot
@@ -5,7 +5,7 @@ import {
buildRemirroredClosedTabMarkerLiftPatch,
buildRetractedMirroredTabSweepPatch
} from './agent-status-primitives'
import { isWebSessionTabsWorktreeRemovalFrame } from './tracking'
import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence'
type FinalPatchContext = ReturnType<typeof applyActiveStateUpdates>
@@ -78,6 +78,7 @@ export function handleGlobalSessionInventoryEvent({
visibilityGeneration,
inventoryFrame,
event.snapshots,
event.authoritative === true,
runtimeId
)
const finishRecoveries = event.snapshots.map((snapshot, index) =>
@@ -0,0 +1,133 @@
import type {
RuntimeMobileSessionTabsRemovedResult,
RuntimeMobileSessionTabsResult
} from '../../../../shared/runtime-types'
import {
MAX_TRACKED_SESSION_TABS_INVENTORY_OMISSIONS,
VISIBILITY_INVENTORY_REMOVAL_EPOCH,
latestSessionTabsSnapshotByWorktree,
sessionTabsInventoryOmissionsByWorktree,
type TrackedWebSessionTabsWorktree
} from './state'
import { sessionTabsFreshnessKey } from './tracking'
function omissionKey(environmentId: string, worktreeId: string): string {
return `${environmentId}:${worktreeId}`
}
function trackedWorktreeOmissionFingerprint(
trackedWorktree: TrackedWebSessionTabsWorktree
): string {
return [
trackedWorktree.freshness.publicationEpoch,
trackedWorktree.freshness.snapshotVersion
].join('\0')
}
export function clearTrackedWebSessionTabsInventoryAbsence(
environmentId: string,
worktreeId: string
): void {
sessionTabsInventoryOmissionsByWorktree.delete(omissionKey(environmentId, worktreeId))
}
/**
* Returns true only after two inventories omit the same tracked identity,
* mirroring `confirmSurfaceInventoryAbsence`. One omission from a census the
* host declined to label authoritative is a visibility fact, never attestation
* that the worktree is gone.
*/
export function confirmTrackedWebSessionTabsInventoryAbsence(
environmentId: string,
trackedWorktree: TrackedWebSessionTabsWorktree
): boolean {
const key = omissionKey(environmentId, trackedWorktree.worktree)
const fingerprint = trackedWorktreeOmissionFingerprint(trackedWorktree)
const cached = sessionTabsInventoryOmissionsByWorktree.get(key)
const observations = cached?.fingerprint === fingerprint ? cached.observations + 1 : 1
sessionTabsInventoryOmissionsByWorktree.delete(key)
sessionTabsInventoryOmissionsByWorktree.set(key, {
fingerprint,
observations: Math.min(observations, 2)
})
while (
sessionTabsInventoryOmissionsByWorktree.size > MAX_TRACKED_SESSION_TABS_INVENTORY_OMISSIONS
) {
const oldest = sessionTabsInventoryOmissionsByWorktree.keys().next().value
if (typeof oldest !== 'string') {
break
}
sessionTabsInventoryOmissionsByWorktree.delete(oldest)
}
return observations >= 2
}
export function isTrackedWebSessionTabsOmissionCurrent(
environmentId: string,
trackedWorktree: TrackedWebSessionTabsWorktree
): boolean {
const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree)
const current = latestSessionTabsSnapshotByWorktree.get(key)
return (
current?.publicationEpoch === trackedWorktree.freshness.publicationEpoch &&
current.snapshotVersion === trackedWorktree.freshness.snapshotVersion
)
}
// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a
// visibility fact, never evidence that the host closed anything.
export function isWebSessionTabsWorktreeRemovalFrame(
snapshot: RuntimeMobileSessionTabsResult
): boolean {
return (
(snapshot as { removed?: unknown }).removed === true ||
snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH
)
}
/**
* Why: a tombstone empties a whole worktree mirror, so it needs the same host
* evidence `mirror-settle` already demands before it will settle an empty
* inventory. An inventory the host labels `authoritative` carries a complete
* PTY census, so one omission is attestation. An unlabelled inventory is a
* degraded or version-skewed census — `unverifiable`, not `exited` — so it must
* repeat before it can destroy anything.
*/
export function buildMissingWebSessionTabsRemovals(
environmentId: string,
trackedWorktrees: readonly TrackedWebSessionTabsWorktree[],
publishedWorktrees: ReadonlySet<string>,
hostAuthoritative: boolean
): {
trackedWorktree: TrackedWebSessionTabsWorktree
snapshot: RuntimeMobileSessionTabsRemovedResult
}[] {
return trackedWorktrees
.filter((trackedWorktree) => {
if (publishedWorktrees.has(trackedWorktree.worktree)) {
clearTrackedWebSessionTabsInventoryAbsence(environmentId, trackedWorktree.worktree)
return false
}
if (!isTrackedWebSessionTabsOmissionCurrent(environmentId, trackedWorktree)) {
return false
}
if (hostAuthoritative) {
clearTrackedWebSessionTabsInventoryAbsence(environmentId, trackedWorktree.worktree)
return true
}
return confirmTrackedWebSessionTabsInventoryAbsence(environmentId, trackedWorktree)
})
.map((trackedWorktree) => ({
trackedWorktree,
snapshot: {
worktree: trackedWorktree.worktree,
publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH,
snapshotVersion: 0,
removed: true,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
}))
}
@@ -108,6 +108,15 @@ export const trackedSessionTabsWorktreeIdsByEnvironment = new Map<string, Set<st
export const sessionTabsEnvironmentsByWorktree = new Map<string, Set<string>>()
export const sessionTabsTrackingGenerationByEnvironment = new Map<string, number>()
export const lastHostTerminalTabCountByWorktree = new Map<string, number>()
export const MAX_TRACKED_SESSION_TABS_INVENTORY_OMISSIONS = 512
export type SessionTabsInventoryOmissionObservation = {
fingerprint: string
observations: number
}
export const sessionTabsInventoryOmissionsByWorktree = new Map<
string,
SessionTabsInventoryOmissionObservation
>()
export const hostSessionTabIdByLocalKey = new Map<string, string>()
export const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map<
string,
@@ -12,6 +12,7 @@ import {
sessionTabsEnvironmentsByWorktree,
sessionTabsTrackingGenerationByEnvironment,
lastHostTerminalTabCountByWorktree,
sessionTabsInventoryOmissionsByWorktree,
hostSessionTabIdByLocalKey,
hostSessionTabMappingKeysByEnvironmentAndWorktree,
hostWorkingClientBoundaryByPaneKey,
@@ -89,6 +90,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
sessionTabsEnvironmentsByWorktree.clear()
resetReceivedSessionTabsFrameSequence()
lastHostTerminalTabCountByWorktree.clear()
sessionTabsInventoryOmissionsByWorktree.clear()
hostSessionTabIdByLocalKey.clear()
hostSessionTabMappingKeysByEnvironmentAndWorktree.clear()
hostWorkingClientBoundaryByPaneKey.clear()
@@ -135,6 +137,7 @@ export function clearWebSessionTabsTrackingForWorktree(
untrackWebSessionTabsWorktree(environmentId, worktreeId)
removeWebSessionTabsEnvironment(environmentId, worktreeId)
lastHostTerminalTabCountByWorktree.delete(key)
sessionTabsInventoryOmissionsByWorktree.delete(key)
clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId)
clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId)
clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId)
@@ -196,6 +199,11 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
lastHostTerminalTabCountByWorktree.delete(key)
}
}
for (const key of sessionTabsInventoryOmissionsByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
sessionTabsInventoryOmissionsByWorktree.delete(key)
}
}
const mappingKeysByWorktree =
hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId)
if (mappingKeysByWorktree) {
@@ -1,9 +1,5 @@
import type {
RuntimeMobileSessionTabsRemovedResult,
RuntimeMobileSessionTabsResult
} from '../../../../shared/runtime-types'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
VISIBILITY_INVENTORY_REMOVAL_EPOCH,
latestReceivedSessionTabsInventoryFrameByEnvironment,
latestReceivedSessionTabsSnapshotByWorktree,
latestSessionTabsRemovalFenceByWorktree,
@@ -237,18 +233,6 @@ export function shouldApplyRecoveredWebSessionTabsSnapshot(
return snapshot.snapshotVersion >= latest.snapshotVersion
}
export function isTrackedWebSessionTabsOmissionCurrent(
environmentId: string,
trackedWorktree: TrackedWebSessionTabsWorktree
): boolean {
const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree)
const current = latestSessionTabsSnapshotByWorktree.get(key)
return (
current?.publicationEpoch === trackedWorktree.freshness.publicationEpoch &&
current.snapshotVersion === trackedWorktree.freshness.snapshotVersion
)
}
export function recordAcceptedWebSessionTabsEnvironment(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
@@ -276,48 +260,6 @@ export function removeWebSessionTabsEnvironment(environmentId: string, worktreeI
}
}
// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a
// visibility fact, never evidence that the host closed anything.
export function isWebSessionTabsWorktreeRemovalFrame(
snapshot: RuntimeMobileSessionTabsResult
): boolean {
return (
(snapshot as { removed?: unknown }).removed === true ||
snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH
)
}
// Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever
// scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived.
export function buildMissingWebSessionTabsRemovals(
environmentId: string,
trackedWorktrees: readonly TrackedWebSessionTabsWorktree[],
publishedWorktrees: ReadonlySet<string>
): {
trackedWorktree: TrackedWebSessionTabsWorktree
snapshot: RuntimeMobileSessionTabsRemovedResult
}[] {
return trackedWorktrees
.filter(
(trackedWorktree) =>
!publishedWorktrees.has(trackedWorktree.worktree) &&
isTrackedWebSessionTabsOmissionCurrent(environmentId, trackedWorktree)
)
.map((trackedWorktree) => ({
trackedWorktree,
snapshot: {
worktree: trackedWorktree.worktree,
publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH,
snapshotVersion: 0,
removed: true,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
}))
}
export function rememberHostTerminalTabCount(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
@@ -280,6 +280,7 @@ export class VisibilityResumeCoordinator {
visibilityGeneration: number,
inventoryReceivedFrame: number,
snapshots: readonly RuntimeMobileSessionTabsResult[],
hostAuthoritative: boolean,
runtimeId?: string
): VisibilityResumeMissing[] {
return recordVisibilityResumeInventoryReceipt({
@@ -289,6 +290,7 @@ export class VisibilityResumeCoordinator {
visibilityGeneration,
inventoryReceivedFrame,
snapshots,
hostAuthoritative,
runtimeId
})
}
@@ -1,9 +1,6 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
buildMissingWebSessionTabsRemovals,
recordReceivedWebSessionTabsRemoval,
sessionTabsFreshnessKey
} from './tracking'
import { recordReceivedWebSessionTabsRemoval, sessionTabsFreshnessKey } from './tracking'
import { buildMissingWebSessionTabsRemovals } from './session-tabs-inventory-absence'
import { isCurrentSessionTabsRuntimeFrame } from './publisher-identity-fences'
import type { VisibilityResumeOmission } from './state'
import type { VisibilityResumeBatch, VisibilityResumeMissing } from './visibility-resume-types'
@@ -15,6 +12,7 @@ export function recordVisibilityResumeInventoryReceipt(args: {
visibilityGeneration: number
inventoryReceivedFrame: number
snapshots: readonly RuntimeMobileSessionTabsResult[]
hostAuthoritative: boolean
runtimeId?: string
}): VisibilityResumeMissing[] {
const {
@@ -24,6 +22,7 @@ export function recordVisibilityResumeInventoryReceipt(args: {
visibilityGeneration,
inventoryReceivedFrame,
snapshots,
hostAuthoritative,
runtimeId
} = args
if (!isCurrentSessionTabsRuntimeFrame(environmentId, runtimeId)) {
@@ -50,7 +49,8 @@ export function recordVisibilityResumeInventoryReceipt(args: {
return buildMissingWebSessionTabsRemovals(
environmentId,
environment.trackedWorktrees,
publishedWorktrees
publishedWorktrees,
hostAuthoritative
).map((missing) => {
const key = sessionTabsFreshnessKey(environmentId, missing.snapshot.worktree)
omissions.set(key, {
@@ -81,7 +81,12 @@ export type AgentStatusPayload = ParsedAgentStatusPayload & {
observation?: AgentStatusObservation
}
export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number }
export type AgentStatusTiming = {
updatedAt?: number
/** Observation clock for staleness; see `AgentStatusEntry.evidenceObservedAt`. */
evidenceObservedAt?: number
stateStartedAt?: number
}
export type AgentStatusRouting = {
tabId?: string
@@ -1,6 +1,9 @@
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
agentStatusEvidenceObservedAt
} from '../../../../shared/agent-status-types'
export type FreshnessSchedulerDeps = {
getEntries: () => AgentStatusEntry[]
@@ -67,7 +70,7 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes
// future timer: the setAgentStatus write already bumped the epoch, so
// freshness-aware selectors can decay them immediately on that render.
for (const entry of entries) {
const expiryAt = entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS
const expiryAt = agentStatusEvidenceObservedAt(entry) + AGENT_STATUS_STALE_AFTER_MS
if (expiryAt >= now) {
nextExpiryAt = Math.min(nextExpiryAt, expiryAt)
}
@@ -222,6 +222,11 @@ export function buildAgentStatusLiveEntry(
workingMode: payload.workingMode,
prompt: payload.prompt,
updatedAt,
// Why: a writer that carries no observation clock (OSC bytes, launch seeds) is itself
// fresh evidence, so it must not inherit the previous row's older observation time.
...(timing?.evidenceObservedAt !== undefined
? { evidenceObservedAt: timing.evidenceObservedAt }
: {}),
stateStartedAt,
agentType: identity.agentType,
model:
+5
View File
@@ -34,6 +34,11 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
connectionId: string | null
/** Timestamp (ms) when the hook server received this latest status event. */
receivedAt: number
/** When the reported evidence was first observed, as distinct from `receivedAt` (delivery
* order). A relay reconnect replays cached rows, and `receivedAt` must restamp to stay
* monotonic past the transient-clear watermark — so only this clock can measure staleness.
* Optional: absent from old hosts, where consumers fall back to `receivedAt`. */
evidenceObservedAt?: number
/** Timestamp (ms) when the current state first appeared for this pane. */
stateStartedAt: number
orchestration?: AgentStatusOrchestrationContext
+17 -2
View File
@@ -106,6 +106,10 @@ export type AgentStatusEntry = {
prompt: string
/** Timestamp (ms) of the last status update. */
updatedAt: number
/** Timestamp (ms) the reported evidence was first observed. Separate from `updatedAt`,
* which is the delivery/ordering clock a relay reconnect must restamp to stay monotonic.
* Absent for locally derived rows and old hosts; freshness falls back to `updatedAt`. */
evidenceObservedAt?: number
/** Timestamp (ms) when the current `state` was first reported.
* Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */
stateStartedAt: number
@@ -258,8 +262,19 @@ export const AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH = 16000
*/
export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
/** Age the staleness window measures: when the evidence was observed, not when it was delivered.
* A relay reconnect replays a cached row and must restamp `updatedAt`, so measuring against it
* pushes the deadline out by another window on every reconnect. */
export function agentStatusEvidenceObservedAt(
entry: Pick<AgentStatusEntry, 'updatedAt' | 'evidenceObservedAt'>
): number {
return entry.evidenceObservedAt ?? entry.updatedAt
}
export function isFreshNonDoneAgentStatus(
entry: Pick<AgentStatusEntry, 'state' | 'updatedAt' | 'restoredUnconfirmed'> | undefined,
entry:
| Pick<AgentStatusEntry, 'state' | 'updatedAt' | 'evidenceObservedAt' | 'restoredUnconfirmed'>
| undefined,
now = Date.now(),
staleAfterMs = AGENT_STATUS_STALE_AFTER_MS
): boolean {
@@ -268,7 +283,7 @@ export function isFreshNonDoneAgentStatus(
entry &&
entry.state !== 'done' &&
entry.restoredUnconfirmed !== true &&
now - entry.updatedAt <= staleAfterMs
now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs
)
}