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