Files
orca/src/main/agent-hooks/server/server-state.ts
T
Neil 4e35e058fc 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
2026-09-01 22:43:38 -07:00

222 lines
11 KiB
TypeScript

import type { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import {
createHookListenerState,
type HookListenerState
} from '../../../shared/agent-hook-listener/listener-state'
import {
createHookTransportInterferenceTracker,
describeHookTransportInterference,
type HookTransportInterferenceReport
} from '../../../shared/agent-hook-transport-interference'
import {
AgentStatusObservationSequencer,
createAgentStatusAuthorityId,
type AgentStatusObservation,
type AgentStatusObservationOrigin
} from '../../../shared/agent-status-observation'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { AgentHookSource } from '../../../shared/agent-hook-relay'
import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { SpoolRecord } from '../../../shared/agent-hook-spool'
import type {
AgentHookAuthorityEvidence,
AgentHookProviderSessionIdentity,
AgentHookStatusChangeEntry,
AgentPromptSentDedupeEntry,
EnrichedAgentHookEventPayload,
NormalizedLocalHook,
PaneKeyAliasEntry,
PaneKeyAliasPersistenceListener,
PaneStatusClearListener,
ProviderSessionChangeListener,
RetiredPaneAlias,
RetiredPaneFence,
ServerAgentStatusListener,
ServerStatusLineListener,
StatusChangeListener,
StatusDropListener
} from './server-types'
/** Shared mutable state for the layered hook-server implementation. */
export abstract class AgentHookServerState {
protected server: ReturnType<typeof createServer> | null = null
protected port = 0
protected token = ''
// Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
protected env = 'production'
protected onAgentStatus: ServerAgentStatusListener = null
protected onClaudeStatusLine: ServerStatusLineListener = null
protected onPaneStatusCleared: PaneStatusClearListener | null = null
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
protected statusDropListeners = new Set<StatusDropListener>()
protected statusChangeListeners = new Set<StatusChangeListener>()
protected providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
// Why: setListener is a single slot owned by the main-window fanout; the
// plugin event bus (and future consumers) need an additive subscription
// that also works in headless serve, where no window listener exists.
protected enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>()
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
protected endpointDir: string | null = null
protected endpointFilePathCache: string | null = null
protected endpointFileWritten = false
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
protected state: HookListenerState = createHookListenerState()
protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null =
null
protected transportInterference = createHookTransportInterferenceTracker(
(report: HookTransportInterferenceReport) => {
console.warn(describeHookTransportInterference(report))
this.onTransportInterference?.(report)
}
)
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
protected runtimeObservedStatusPaneKeys = new Set<string>()
protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
protected hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
protected persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
protected revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
protected currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
protected legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
// Why: indexed by every key the retirement fenced, so a re-attach on any of them
// (owner, physical, or a deleted alias) finds the same record. Bounded like the maps
// it mirrors; an evicted record simply degrades to lifting the key it was handed.
protected retiredPaneFencesByKey = new Map<string, RetiredPaneFence>()
protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
protected lastStatusFilePath: string | null = null
// Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state.
protected statusPersistTimer: ReturnType<typeof setTimeout> | null = null
protected assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
protected promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
protected activeHookTurnCompletedAtByPaneKey = new Map<string, number>()
protected promptSentHashSalt = randomBytes(16).toString('hex')
protected closedAgentStatusTabIds = new Set<string>()
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
// OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here.
protected readonly observations = new AgentStatusObservationSequencer(
createAgentStatusAuthorityId('main-agent-hooks')
)
protected abstract withdrawReplayObservation(paneKey: string): void
protected abstract ingestSpoolRecord(record: SpoolRecord): void
protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void
protected abstract buildStatusChangeNotification(): {
statuses: AgentHookStatusChangeEntry[]
providerSessions: AgentHookProviderSessionIdentity[]
}
protected abstract notifyStatusChangeListeners(): void
protected abstract markTabClosedForAgentStatus(tabId: string): void
protected abstract getAgentStatusDisposition(
paneKey: string,
event?: {
source?: AgentHookSource
rawSource?: unknown
hookEventName?: string
isReplay?: boolean
hasExplicitPrompt?: boolean
launchToken?: string
}
): 'accept' | 'restart' | 'suppress'
protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean
protected abstract recordRetiredPaneFence(
paneKeys: ReadonlySet<string>,
aliases: readonly RetiredPaneAlias[]
): void
protected abstract markPaneClosedForAgentStatus(paneKey: string): void
protected abstract attachStatusTiming(
payload: AgentHookEventPayload,
now?: number
): EnrichedAgentHookEventPayload
protected abstract hashPromptForTelemetryDedupe(prompt: string): string
protected abstract maybeTrackAgentPromptSent(
payload: AgentHookEventPayload,
previousStatus: EnrichedAgentHookEventPayload | undefined
): void
protected abstract stampObservation(
payload: AgentHookEventPayload,
origin: AgentStatusObservationOrigin,
observedAt: number
): AgentStatusObservation
protected abstract applyNormalizedStatus(
payload: AgentHookEventPayload,
onAccepted?: () => void,
origin?: AgentStatusObservationOrigin
): EnrichedAgentHookEventPayload
protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void
protected abstract clearAssistantMessageRetry(paneKey: string): void
protected abstract clearCodexSubagentPoll(paneKey: string): void
protected abstract clearAllCodexSubagentPolls(): void
protected abstract scheduleCodexSubagentPoll(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload
): void
protected abstract scheduleAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload,
attempt?: number,
discoveryReady?: boolean
): void
protected abstract applyAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload,
nextAttempt: number,
requireExactOriginal: boolean
): void
protected abstract getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[]
protected abstract notifyPaneKeyAliasPersistenceListener(): void
protected abstract boundPaneKeyAliases(): void
protected abstract getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string
protected abstract restoreRetiredPaneFence(fence: RetiredPaneFence): void
protected abstract revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet<string>): boolean
protected abstract resolvePaneKeyAlias(paneKey: string): string
protected abstract normalizeHookBodyPaneKeyAlias(body: unknown): unknown
protected abstract normalizeLocalHookPayload(
source: AgentHookSource,
body: unknown
): NormalizedLocalHook
protected abstract setClaudeBackgroundEvidence(
paneKey: string,
hasRunningTask: boolean,
hasActiveCron: boolean
): void
protected abstract toRetainedProviderSessionRow(
entry: EnrichedAgentHookEventPayload | null | undefined
): EnrichedAgentHookEventPayload | null
protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean
protected abstract clearPaneState(paneKey: string): void
protected abstract deleteStatusEntry(
paneKey: string,
options?: { preserveAuthority?: boolean }
): EnrichedAgentHookEventPayload | null
protected abstract maybeWriteEndpointFile(): void
protected abstract hydrateLastStatusFromDisk(): void
protected abstract captureHydratedAuthorityCommitments(): void
protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void
protected abstract toAuthorityEvidence(
payload: AgentHookEventPayload | EnrichedAgentHookEventPayload,
launchTokenHashOverride?: string
): AgentHookAuthorityEvidence | null
protected abstract serializeStatusFile(): string
protected abstract scheduleStatusPersist(): void
protected abstract runStatusPersist(): void
abstract _getStateForTests(): HookListenerState
abstract _resetPromptSentDedupeForTests(): void
abstract _resetConnectionTimestampWatermarksForTests(): void
}