From 4e35e058fcf091a0875dd81b2cd750bdf30d7b9f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:43:38 -0700 Subject: [PATCH] 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 --- .../server-replay-evidence-clock.test.ts | 102 +++++++++ src/main/agent-hooks/server/server-state.ts | 4 + .../server/server-status-application.ts | 31 +++ .../agent-hooks/server/server-tab-cleanup.ts | 3 + src/main/agent-hooks/server/server-types.ts | 5 + src/main/startup/main-window-agent-status.ts | 3 + .../agent-status-event-applicator.ts | 3 + .../lib/agent-status-evidence-clock.test.ts | 44 ++++ src/renderer/src/lib/pane-agent-evidence.ts | 7 +- ...sion-mirror-settle-receipt-frames.test.tsx | 3 +- ...on-tabs-sync-visibility-collision.test.tsx | 21 +- ...ssion-tabs-sync-window-visibility.test.tsx | 198 ++++++++++-------- .../apply-final-patch.ts | 2 +- .../global-session-inventory-event.ts | 1 + .../session-tabs-inventory-absence.ts | 133 ++++++++++++ .../runtime/web-session-tabs-sync/state.ts | 9 + .../tracking-lifecycle.ts | 8 + .../runtime/web-session-tabs-sync/tracking.ts | 60 +----- .../visibility-resume-coordinator.ts | 2 + .../visibility-resume-inventory.ts | 12 +- .../src/store/slices/agent-status-contract.ts | 7 +- .../agent-status-freshness-scheduler.ts | 7 +- .../slices/agent-status-live-entry-builder.ts | 5 + src/shared/agent-status-ipc-payload.ts | 5 + src/shared/agent-status-types.ts | 19 +- 25 files changed, 520 insertions(+), 174 deletions(-) create mode 100644 src/main/agent-hooks/server-replay-evidence-clock.test.ts create mode 100644 src/renderer/src/lib/agent-status-evidence-clock.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts diff --git a/src/main/agent-hooks/server-replay-evidence-clock.test.ts b/src/main/agent-hooks/server-replay-evidence-clock.test.ts new file mode 100644 index 00000000000..12475a35d64 --- /dev/null +++ b/src/main/agent-hooks/server-replay-evidence-clock.test.ts @@ -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, + 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) + }) +}) diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 956be136ca6..7dc8125576e 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -97,6 +97,10 @@ export abstract class AgentHookServerState { protected closedAgentStatusPaneKeys = new Set() protected restartedStatusLaunchTokenHashByPaneKey = new Map() protected connectionTimestampWatermarkById = new Map() + // 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() // 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 diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 7fbb6a96bc1..f7e1126d11b 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -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) diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 4abacfc81d0..3ce2c4fce0a 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -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 } } diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 913bcd7067e..c151c70d34b 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -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 diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 2e583830a48..3b2ba9cbd71 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -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 } : {}), diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 4e24bf97168..c0ce037a0dc 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -225,6 +225,9 @@ export function createAgentStatusEventApplicator(args: { terminalTitle, timing: { updatedAt: data.receivedAt, + ...(data.evidenceObservedAt !== undefined + ? { evidenceObservedAt: data.evidenceObservedAt } + : {}), stateStartedAt: data.stateStartedAt }, routing: { diff --git a/src/renderer/src/lib/agent-status-evidence-clock.test.ts b/src/renderer/src/lib/agent-status-evidence-clock.test.ts new file mode 100644 index 00000000000..9d10f72e2c1 --- /dev/null +++ b/src/renderer/src/lib/agent-status-evidence-clock.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index cfed24bfa33..69ba64131a9 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -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, + entry: Pick, 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 + ) } /** diff --git a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx index b07bce1052c..644debd3eb0 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx @@ -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, diff --git a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx index 693dcbb823b..d17a6898d93 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx @@ -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() diff --git a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx index 187fb213b3f..b30662524d9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx @@ -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 => { + 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() 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() 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 => { - 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 diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts index e15e6369c41..4ef51cd52f7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -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 diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index f8f93f0894d..dbcd22158a4 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -78,6 +78,7 @@ export function handleGlobalSessionInventoryEvent({ visibilityGeneration, inventoryFrame, event.snapshots, + event.authoritative === true, runtimeId ) const finishRecoveries = event.snapshots.map((snapshot, index) => diff --git a/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts b/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts new file mode 100644 index 00000000000..5b4a716fbb7 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts @@ -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, + 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: [] + } + })) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index bb693160e4a..d8be13bde50 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -108,6 +108,15 @@ export const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() export const sessionTabsTrackingGenerationByEnvironment = new Map() export const lastHostTerminalTabCountByWorktree = new Map() +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() export const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< string, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts index e352a439c9c..8b07fde7a33 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -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) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts index ecbd20bba7c..1d6eea41055 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -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 -): { - 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 diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts index 7d02f2b38af..b905143a12d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts @@ -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 }) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts index 3398595b01b..16541f98a31 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts @@ -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, { diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 457da539318..65b91e4074b 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -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 diff --git a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts index ee8c8b8f524..c2ed805c3f4 100644 --- a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts +++ b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts @@ -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) } diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 3ef257edb6b..12812b380c7 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -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: diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 71c830714eb..6a469b31491 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -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 diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index ea377ae4f99..6ce3cd0647e 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -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 +): number { + return entry.evidenceObservedAt ?? entry.updatedAt +} + export function isFreshNonDoneAgentStatus( - entry: Pick | undefined, + entry: + | Pick + | 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 ) }