From 26bdfc0fe4810c2d0b8be677e32c55682f296ff2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:46:41 -0700 Subject: [PATCH] feat(agent-status): stamp observation provenance at every status ingress (STA-4293) (#14706) Add an optional `observation` facet to agent status rows recording the origin (hook | osc | title | process | launch | orchestration), the authority that sequenced it, a per-pane incarnation, a monotonic revision, and the authority's own clock. Stamp it at every ingress; no consumer reads it. Boundary is stamped from the hook listener's existing per-provider `isNewTurnEvent`, not a second list of event-name literals. Identity-only (`providerSessionOnly`) rows are tagged `kind: 'identity-only'` so future consumers do not each rediscover that they are not turn transitions. The staleness-decay contract is documented at the type: staleness must be computed against the same authority clock that stamped `observedAt`, or replicas must decay on local receipt time. Not fixed here. Behavior-neutral: optional field on existing JSON, never persisted, never published to paired clients, and never inherited across writes. --- .../server-observation-provenance.test.ts | 267 ++++++++++++++++++ src/main/agent-hooks/server.ts | 95 ++++++- src/main/index.ts | 3 + .../worktree-title-derived-agent-rows.ts | 22 +- .../parked-terminal-command-status.test.ts | 18 +- .../parked-terminal-command-status.ts | 15 +- ...y-connection-hidden-output-restore.test.ts | 9 +- ...onnection-startup-command-delivery.test.ts | 11 +- ...-connection-task-complete-dispatch.test.ts | 6 +- .../terminal-pane/pty-connection.ts | 51 +++- src/renderer/src/hooks/useIpcEvents.ts | 7 +- .../src/lib/automation-session-observer.ts | 16 +- .../lib/background-agent-status-consumer.ts | 34 ++- .../lib/command-code-prompt-status-seed.ts | 12 +- .../launch-agent-background-session.test.ts | 4 +- .../lib/launch-agent-background-session.ts | 3 +- .../src/lib/launch-agent-in-new-tab.test.ts | 4 +- ...r-agent-status-observation-ingress.test.ts | 108 +++++++ .../lib/renderer-agent-status-observations.ts | 17 ++ ...gent-status-observation-neutrality.test.ts | 170 +++++++++++ src/renderer/src/store/slices/agent-status.ts | 15 + src/shared/agent-hook-listener.ts | 4 +- src/shared/agent-status-observation.test.ts | 124 ++++++++ src/shared/agent-status-observation.ts | 172 +++++++++++ src/shared/agent-status-types.ts | 5 +- 25 files changed, 1146 insertions(+), 46 deletions(-) create mode 100644 src/main/agent-hooks/server-observation-provenance.test.ts create mode 100644 src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts create mode 100644 src/renderer/src/lib/renderer-agent-status-observations.ts create mode 100644 src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts create mode 100644 src/shared/agent-status-observation.test.ts create mode 100644 src/shared/agent-status-observation.ts diff --git a/src/main/agent-hooks/server-observation-provenance.test.ts b/src/main/agent-hooks/server-observation-provenance.test.ts new file mode 100644 index 00000000000..74d143cec11 --- /dev/null +++ b/src/main/agent-hooks/server-observation-provenance.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { AgentStatusObservation } from '../../shared/agent-status-observation' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey('tab-1', LEAF) +const CONNECTION = 'ssh-provenance' + +type Observed = { paneKey: string; observation?: AgentStatusObservation } + +function collectObservations(server: AgentHookServer): Observed[] { + const seen: Observed[] = [] + server.setListener((payload) => { + seen.push({ paneKey: payload.paneKey, observation: payload.observation }) + }) + return seen +} + +function lastObservation(seen: Observed[]): AgentStatusObservation { + const observation = seen.at(-1)?.observation + if (!observation) { + throw new Error('expected the last emitted status to carry an observation') + } + return observation +} + +describe('agent status observation provenance', () => { + const servers: AgentHookServer[] = [] + + beforeEach(() => { + _internals.resetCachesForTests() + getCohortAtEmitMock.mockReturnValue({}) + }) + + afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 + vi.restoreAllMocks() + }) + + function newServer(): AgentHookServer { + const server = new AgentHookServer() + servers.push(server) + return server + } + + it('stamps relayed hook events as hook-origin transitions', () => { + const server = newServer() + const seen = collectObservations(server) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude', + hookEventName: 'PostToolUse', + payload: { state: 'working', prompt: 'relayed', agentType: 'claude' } + }, + CONNECTION + ) + + expect(lastObservation(seen)).toMatchObject({ + origin: 'hook', + kind: 'transition', + authorityId: expect.stringMatching(/^main-agent-hooks:/), + observedAt: expect.any(Number) + }) + expect(lastObservation(seen).boundary).toBeUndefined() + }) + + it('stamps main-parsed OSC 9999 rows as osc-origin snapshots', () => { + const server = newServer() + const seen = collectObservations(server) + + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + payload: { state: 'working', prompt: 'from bytes', agentType: 'codex' } + }) + + expect(lastObservation(seen)).toMatchObject({ origin: 'osc', kind: 'snapshot' }) + }) + + it('stamps a relay replay as a snapshot, not a fresh transition', () => { + const server = newServer() + const seen = collectObservations(server) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude', + hookEventName: 'UserPromptSubmit', + isReplay: true, + payload: { state: 'working', prompt: 'replayed', agentType: 'claude' } + }, + CONNECTION + ) + + expect(lastObservation(seen).kind).toBe('snapshot') + }) + + it('marks a resume-identity refresh as identity-only rather than a turn transition', () => { + const server = newServer() + const seen = collectObservations(server) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'pi', + hookEventName: 'before_agent_start', + providerSessionOnly: true, + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.json' + }, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + CONNECTION + ) + + expect(lastObservation(seen).kind).toBe('identity-only') + }) + + // Why: this is the whole point of stamping boundary from the listener's own classifier. + // `before_agent_start`, `BeforeAgent` and `agent.start` are NOT the two raw literals the + // retired-pane gate matches, so a second hand-written list here would report them as + // non-boundaries — exactly the defect PR #14626 fixed one instance of. + it.each([ + { source: 'claude', hookEventName: 'UserPromptSubmit', agentType: 'claude' }, + { source: 'pi', hookEventName: 'before_agent_start', agentType: 'pi' }, + { source: 'gemini', hookEventName: 'BeforeAgent', agentType: 'gemini' }, + { source: 'amp', hookEventName: 'agent.start', agentType: 'amp' }, + { source: 'cursor', hookEventName: 'beforeSubmitPrompt', agentType: 'cursor' } + ])( + 'stamps boundary for $source $hookEventName even though it is not a gate literal', + ({ source, hookEventName, agentType }) => { + const server = newServer() + const seen = collectObservations(server) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source, + hookEventName, + payload: { state: 'working', prompt: 'a new turn', agentType } + }, + CONNECTION + ) + + expect(lastObservation(seen).boundary).toBe(true) + } + ) + + it('does not stamp boundary for a mid-turn event of a boundary-capable provider', () => { + const server = newServer() + const seen = collectObservations(server) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'gemini', + hookEventName: 'PostToolUse', + payload: { state: 'working', prompt: 'mid turn', agentType: 'gemini' } + }, + CONNECTION + ) + + expect(lastObservation(seen).boundary).toBeUndefined() + }) + + it('advances revision per accepted observation and bumps incarnation when a retired pane restarts', () => { + const server = newServer() + const seen = collectObservations(server) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude', + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'first turn', agentType: 'claude' } + }, + CONNECTION + ) + const first = lastObservation(seen) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude', + hookEventName: 'Stop', + payload: { state: 'done', prompt: 'first turn', agentType: 'claude' } + }, + CONNECTION + ) + const second = lastObservation(seen) + + expect(second.revision).toBeGreaterThan(first.revision) + expect(second.incarnation).toBe(first.incarnation) + + server.retirePaneAuthority(PANE) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude', + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'reused pane', agentType: 'claude' } + }, + CONNECTION + ) + const restarted = lastObservation(seen) + + expect(restarted.incarnation).toBeGreaterThan(second.incarnation) + expect(restarted.revision).toBeGreaterThan(second.revision) + }) + + it('never persists the observation to last-status.json', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-observation-')) + try { + const server = newServer() + await server.start({ env: 'production', userDataPath }) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source: 'claude', + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'persist me', agentType: 'claude' } + }, + CONNECTION + ) + server.flushStatusPersistSync() + + const path = join(userDataPath, 'agent-hooks', 'last-status.json') + expect(existsSync(path)).toBe(true) + const file = readFileSync(path, 'utf8') + // Why: the sequencer that issued it dies with the process, so a stored copy could only + // rehydrate as an ordering claim from a dead authority. + expect(file).not.toContain('observation') + expect(JSON.parse(file).entries[PANE].observation).toBeUndefined() + } finally { + rmSync(userDataPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 7bb86a34142..c3515f44282 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -18,6 +18,7 @@ import { hasCodexTranscriptSubagents, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, + isNewTurnEvent, markClaudeLeadTurnInterrupted, markCodexLeadTurnInterrupted, MAX_PANE_KEY_LEN, @@ -72,6 +73,12 @@ import { type ParsedAgentStatusPayload, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { + AgentStatusObservationSequencer, + createAgentStatusAuthorityId, + type AgentStatusObservation, + type AgentStatusObservationOrigin +} from '../../shared/agent-status-observation' import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus @@ -103,6 +110,8 @@ export type { AgentHookSource } type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number + /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ + observation?: AgentStatusObservation /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ restoredUnconfirmed?: true /** User-hidden resume identity retained solely for destructive liveness checks. */ @@ -118,7 +127,13 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' + // Why: revision counters are in-memory and the authority id is regenerated per process, so + // a stored observation could only rehydrate as a stale ordering claim from a dead authority. + | 'observation' > & { launchTokenHash?: string } @@ -433,6 +448,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(entry.observation ? { observation: entry.observation } : {}), ...entry.payload } } @@ -736,6 +752,11 @@ export class AgentHookServer { private connectionTimestampWatermarkById = new Map() // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. private 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. + private readonly observations = new AgentStatusObservationSequencer( + createAgentStatusAuthorityId('main-agent-hooks') + ) /** * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). @@ -1275,9 +1296,35 @@ export class AgentHookServer { } } + /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet + * (STA-4293) — it is stamped here because every main-side ingress funnels through + * applyNormalizedStatus, so no origin can silently arrive untagged. */ + private stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation { + return this.observations.observe(payload.paneKey, { + origin, + observedAt, + // Why: reuse the listener's own per-provider classifier; a second list of raw event-name + // literals here would strand the providers whose boundary event is named anything else. + boundary: + payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), + kind: payload.providerSessionOnly + ? 'identity-only' + : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the + // current state rather than announcing a change — neither is a fresh transition. + payload.isReplay === true || origin === 'osc' + ? 'snapshot' + : 'transition' + }) + } + private applyNormalizedStatus( payload: AgentHookEventPayload, - onAccepted?: () => void + onAccepted?: () => void, + origin: AgentStatusObservationOrigin = 'hook' ): EnrichedAgentHookEventPayload { if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. @@ -1302,7 +1349,10 @@ export class AgentHookServer { if (payload.providerSessionOnly) { // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. onAccepted?.() - const enriched = this.attachStatusTiming(payload, now) + const enriched = { + ...this.attachStatusTiming(payload, now), + observation: this.stampObservation(payload, origin, now) + } this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) @@ -1433,7 +1483,10 @@ export class AgentHookServer { this.maybeTrackAgentPromptSent(effectivePayload, previous) } const cachedPayload = resolveCachedClaudeCompactOwnership(previous, boundaryAwarePayload) - const enriched = this.attachStatusTiming(cachedPayload, now) + const enriched = { + ...this.attachStatusTiming(cachedPayload, now), + observation: this.stampObservation(cachedPayload, origin, now) + } if ( typeof enriched.payload.turnCompletedAt === 'number' && Number.isFinite(enriched.payload.turnCompletedAt) @@ -1824,6 +1877,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.delete(key) this.currentAuthorityObservations.delete(key) this.promptSentDedupeByPaneKey.delete(key) + this.observations.forget(key) } if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() @@ -2163,14 +2217,18 @@ export class AgentHookServer { ? previous.providerSession : undefined // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. - this.applyNormalizedStatus({ - paneKey, - tabId, - worktreeId, - connectionId, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: event.payload - }) + this.applyNormalizedStatus( + { + paneKey, + tabId, + worktreeId, + connectionId, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: event.payload + }, + undefined, + 'osc' + ) } /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ @@ -2275,6 +2333,12 @@ export class AgentHookServer { if (statusDisposition === 'suppress') { return } + if (statusDisposition === 'restart') { + // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. + // Why paneKey, not envelope.paneKey: it is already corrected to the session's + // true pane, so the rebind cannot land on the daemon's inherited key. + this.observations.rebind(paneKey) + } // Why: a corrected pane brings its own workspace — the envelope's came from // the same inherited env that named the wrong pane. const worktreeId = @@ -2496,6 +2560,11 @@ export class AgentHookServer { statusDisposition === 'restart' ? { ...normalized.event, launchToken: undefined } : normalized.event + if (statusDisposition === 'restart') { + // Why: a retired pane accepting a new turn is a different agent session behind the + // same key — later observations must not be ordered against the retired one. + this.observations.rebind(event.paneKey) + } this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) @@ -3137,6 +3206,8 @@ export class AgentHookServer { promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, + // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). + observation: _observation, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/index.ts b/src/main/index.ts index c210bb66873..ecd94696965 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1597,6 +1597,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow providerSessionOnly, promptInteractionKey, restoredUnconfirmed, + observation, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1614,6 +1615,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow receivedAt, stateStartedAt, ...(providerSession ? { providerSession } : {}), + ...(observation ? { observation } : {}), providerSessionOnly: true }) return @@ -1645,6 +1647,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(observation ? { observation } : {}), ...(orchestration ? { orchestration } : {}) } mainWindow?.webContents.send('agentStatus:set', statusEvent) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 70289b4b3fb..3d58d67e425 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -22,6 +22,11 @@ import { import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' +/** Fixed, not per-process: title rows are a pure projection of the current title, so they are + * comparable across restarts in a way a sequenced authority's rows are not. Ordering against + * any other authority's rows is undefined — see agent-status-observation.ts. */ +export const TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID = 'renderer-title-projection' + const EMPTY_RUNTIME_TITLES: Record> = {} const EMPTY_LIVE_PTY_IDS: Record = {} const EMPTY_TERMINAL_LAYOUTS: Record = {} @@ -183,7 +188,22 @@ function buildTitleDerivedAgentRow(args: { agentType, terminalTitle: title, lastAssistantMessage: secondary, - ...(orchestration ? { orchestration } : {}) + ...(orchestration ? { orchestration } : {}), + // Why not the renderer sequencer: this row is RE-DERIVED from the pane's title on every + // render, not observed once, so a counter would churn a new revision per frame and break + // memoization. Deriving revision from `now` keeps the stamp deterministic in the same clock + // the row already publishes as updatedAt, and monotonic for the pane. + // The origin tag is the point: `entryState` above collapses a title-derived IDLE row to + // 'working' while the row itself reports idle. That contradiction is out of scope here — + // this tag is what makes it findable instead of indistinguishable from a real hook row. + observation: { + origin: 'title', + authorityId: TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID, + incarnation: 0, + revision: args.now, + observedAt: args.now, + kind: 'snapshot' + } } return { paneKey, diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index e9005fdd11f..3a334300341 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -101,7 +101,13 @@ describe('createParkedTerminalCommandStatusPolicy', () => { expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( PANE_KEY, - { state: 'working', prompt: 'Fix the spinner', agentType: 'command-code' }, + { + state: 'working', + prompt: 'Fix the spinner', + agentType: 'command-code', + // Why: the row is read off the pane's own output, not a provider signal (STA-4293). + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) + }, '✳ Build feature', undefined, ROUTING @@ -184,7 +190,8 @@ describe('createParkedTerminalCommandStatusPolicy', () => { { state: 'working', prompt: 'New Command Code prompt', - agentType: 'command-code' + agentType: 'command-code', + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) }, '✳ Build feature', undefined, @@ -222,7 +229,12 @@ describe('createParkedTerminalCommandStatusPolicy', () => { expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( PANE_KEY, - { state: 'done', prompt: 'Fix the spinner', agentType: 'command-code' }, + { + state: 'done', + prompt: 'Fix the spinner', + agentType: 'command-code', + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) + }, '✳ Build feature', undefined, ROUTING diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 9f54e02e87f..6c2f20b4891 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -10,6 +10,7 @@ import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' +import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' import { useAppStore } from '@/store' import { cancelCommandCodeDoneSettle, @@ -127,7 +128,12 @@ export function createParkedTerminalCommandStatusPolicy(options: { { state: 'done', prompt: currentPrompt || normalizedPrompt, - agentType: 'command-code' + agentType: 'command-code', + observation: rendererAgentStatusObservations.observe(paneKey, { + origin: 'process', + observedAt: Date.now(), + kind: 'transition' + }) }, currentTitle, undefined, @@ -189,7 +195,12 @@ export function createParkedTerminalCommandStatusPolicy(options: { state: 'working', prompt: normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''), - agentType: 'command-code' + agentType: 'command-code', + observation: rendererAgentStatusObservations.observe(paneKey, { + origin: 'process', + observedAt: Date.now(), + kind: 'transition' + }) }, currentTitle, undefined, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts index 75ed3a640fa..5835e55fff7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts @@ -543,7 +543,14 @@ describe('connectPanePty', () => { expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( makePaneKey('tab-1', LEAF_1), - { state: 'working', prompt: 'paired task', agentType: 'claude' }, + { + state: 'working', + prompt: 'paired task', + agentType: 'claude', + // Why: the renderer parsed these OSC 9999 bytes itself for a remote-runtime pane, so it + // is the sequencing authority for the row (STA-4293). + observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) + }, undefined, undefined, { connectionId: null } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts index a04ea546cf8..969bf836594 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts @@ -216,7 +216,9 @@ describe('connectPanePty', () => { { state: 'working', prompt: 'Fix the status', - agentType: 'command-code' + agentType: 'command-code', + // Why: Orca launched this agent, so the seed predates any provider signal (STA-4293). + observation: expect.objectContaining({ origin: 'launch', kind: 'transition' }) }, undefined, undefined, @@ -258,7 +260,9 @@ describe('connectPanePty', () => { { state: 'working', prompt: 'Fix the spinner', - agentType: 'command-code' + agentType: 'command-code', + // Why: read off the pane's own output, not a provider hook (STA-4293). + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) }, undefined, undefined, @@ -465,7 +469,8 @@ describe('connectPanePty', () => { { state: 'working', prompt: 'Fix the green done state', - agentType: 'command-code' + agentType: 'command-code', + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) }, undefined, undefined, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts index eec5133fed8..3663f07402a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts @@ -500,7 +500,8 @@ describe('connectPanePty', () => { { state: 'working', prompt: 'fix the remote title', - agentType: 'omp' + agentType: 'omp', + observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) }, '\u280b OMP', undefined, @@ -521,7 +522,8 @@ describe('connectPanePty', () => { { state: 'working', prompt: 'keep the remote title', - agentType: 'omp' + agentType: 'omp', + observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) }, '\u280b OMP', undefined, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5bebf788484..abcebbdc417 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3,6 +3,7 @@ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' import type { IBuffer, IDisposable } from '@xterm/xterm' import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' +import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route' import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status' import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report' @@ -2874,7 +2875,13 @@ export function connectPanePty( agentType: resolveCompatibleAgentTypeForOwner( initialStatus.agent, getAuthoritativePaneAgent() - ) + ), + // Why: Orca launched this agent, so this row predates any provider signal for the pane. + observation: rendererAgentStatusObservations.observe(cacheKey, { + origin: 'launch', + observedAt: Date.now(), + kind: 'transition' + }) } if (paneStartup.launchConfig) { useAppStore @@ -2926,7 +2933,13 @@ export function connectPanePty( { state: 'working', prompt: normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''), - agentType: 'command-code' + agentType: 'command-code', + // Why: Command Code has no prompt-start hook; this row is read off the pane's own output. + observation: rendererAgentStatusObservations.observe(cacheKey, { + origin: 'process', + observedAt: Date.now(), + kind: 'transition' + }) }, currentTitle, undefined, @@ -2961,7 +2974,12 @@ export function connectPanePty( { state: 'done', prompt: currentPrompt || normalizedPrompt, - agentType: 'command-code' + agentType: 'command-code', + observation: rendererAgentStatusObservations.observe(cacheKey, { + origin: 'process', + observedAt: Date.now(), + kind: 'transition' + }) }, currentTitle, undefined, @@ -3710,6 +3728,18 @@ export function connectPanePty( const authoritativePaneAgent = getAuthoritativePaneAgent() const agentType = resolveCompatibleAgentTypeForOwner(payload.agentType, authoritativePaneAgent) const statusPayload = agentType === payload.agentType ? payload : { ...payload, agentType } + // Why: this is the remote-runtime path where the renderer, not main, parses OSC 9999 out of + // PTY bytes — so the renderer is the sequencing authority for these rows and says so. Kept + // beside statusPayload, not merged into it, so the notification/title consumers below see + // byte-for-byte what they see today. + const observedStatusPayload = { + ...statusPayload, + observation: rendererAgentStatusObservations.observe(cacheKey, { + origin: 'osc', + observedAt: Date.now(), + kind: 'snapshot' + }) + } const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) const statusTitle = resolvedStatusTitle ? normalizeCompatibleAgentTitleForOwner( @@ -3721,11 +3751,18 @@ export function connectPanePty( // status may fence the host mirror out of its store key. markRendererOwnedAgentStatusWrite(cacheKey) if (launchToken) { - currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing, { - launchToken - }) + currentState.setAgentStatus( + cacheKey, + observedStatusPayload, + statusTitle, + undefined, + routing, + { + launchToken + } + ) } else { - currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing) + currentState.setAgentStatus(cacheKey, observedStatusPayload, statusTitle, undefined, routing) } if (payload.state === 'working' && syncAgentTaskCompleteTrackingEnabled()) { requiresFreshWorkingForAgentTaskCompleteNotification = false diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 03d3b54c5cd..509ccd60fe3 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3414,6 +3414,11 @@ export function useIpcEvents(): void { data.restoredUnconfirmed === true ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } : statusPayloadWithTurnBoundary + // Why: main sequenced this row as the pane authority; carry its stamp rather than + // minting a renderer one, which would claim a second authority for the same observation. + const statusPayloadWithObservation = data.observation + ? { ...statusPayloadWithProvenance, observation: data.observation } + : statusPayloadWithProvenance const identity = resolveAgentStatusIdentity({ existing: existingStatus ? { @@ -3453,7 +3458,7 @@ export function useIpcEvents(): void { const statusWorktreeId = data.worktreeId ?? owningWorktreeId const update: AgentStatusUpdate = { paneKey, - payload: statusPayloadWithProvenance, + payload: statusPayloadWithObservation, terminalTitle, timing: { updatedAt: data.receivedAt, diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index 768f13282ed..a953652fefa 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -12,6 +12,7 @@ import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' +import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' export async function observeExistingAutomationSession(args: { ptyId: string @@ -44,7 +45,20 @@ export async function observeExistingAutomationSession(args: { // Why: a delayed reuse observer must not write into a pane that has // since rebound to another host's colliding tab/pane identifiers. if (routing) { - state.setAgentStatus(paneKey, payload, undefined, undefined, routing) + state.setAgentStatus( + paneKey, + { + ...payload, + observation: rendererAgentStatusObservations.observe(paneKey, { + origin: 'osc', + observedAt: Date.now(), + kind: 'snapshot' + }) + }, + undefined, + undefined, + routing + ) } } args.onAgentStatus(payload) diff --git a/src/renderer/src/lib/background-agent-status-consumer.ts b/src/renderer/src/lib/background-agent-status-consumer.ts index 6868280585c..bc142d85401 100644 --- a/src/renderer/src/lib/background-agent-status-consumer.ts +++ b/src/renderer/src/lib/background-agent-status-consumer.ts @@ -5,6 +5,8 @@ import { resolveLiveAgentStatusConnectionRouting, type AgentStatusConnectionRouting } from './agent-status-connection-ownership' +import { rendererAgentStatusObservations } from './renderer-agent-status-observations' +import type { AgentStatusObservation } from '../../../shared/agent-status-observation' export function createBackgroundAgentStatusConsumer(args: { paneKey: string @@ -17,6 +19,10 @@ export function createBackgroundAgentStatusConsumer(args: { }): { consume: (data: string) => void resolveRouting: () => AgentStatusConnectionRouting | undefined + /** Stamp a launch-origin observation for this pane (STA-4293). Lives here because this + * consumer already owns the pane's status ingress; callers seeding a launch row need the + * same authority the byte path writes under. */ + observeLaunchIngress: () => AgentStatusObservation } { const processAgentStatus = createAgentStatusOscProcessor() const resolveRouting = (): AgentStatusConnectionRouting | undefined => { @@ -38,15 +44,31 @@ export function createBackgroundAgentStatusConsumer(args: { // Why: hidden callbacks can outlive tab reuse; only the exact current // pane-to-PTY binding may update its status ownership. if (routing) { - useAppStore - .getState() - .setAgentStatus(args.paneKey, payload, undefined, undefined, routing, { - launchToken: args.launchToken - }) + useAppStore.getState().setAgentStatus( + args.paneKey, + { + ...payload, + observation: rendererAgentStatusObservations.observe(args.paneKey, { + origin: 'osc', + observedAt: Date.now(), + kind: 'snapshot' + }) + }, + undefined, + undefined, + routing, + { launchToken: args.launchToken } + ) } } args.onAgentStatus?.(payload) } } - return { consume, resolveRouting } + const observeLaunchIngress = (): AgentStatusObservation => + rendererAgentStatusObservations.observe(args.paneKey, { + origin: 'launch', + observedAt: Date.now(), + kind: 'transition' + }) + return { consume, resolveRouting, observeLaunchIngress } } diff --git a/src/renderer/src/lib/command-code-prompt-status-seed.ts b/src/renderer/src/lib/command-code-prompt-status-seed.ts index e918d6bf988..5157c8c0583 100644 --- a/src/renderer/src/lib/command-code-prompt-status-seed.ts +++ b/src/renderer/src/lib/command-code-prompt-status-seed.ts @@ -2,6 +2,7 @@ import { useAppStore } from '@/store' import { makePaneKey } from '../../../shared/stable-pane-id' import { getConnectionIdFromState } from './connection-owner-resolution' import { resolveLiveAgentStatusConnectionRouting } from './agent-status-connection-ownership' +import { rendererAgentStatusObservations } from './renderer-agent-status-observations' /** * Why: Command Code has no prompt-submit hook, so when Orca submits a generated @@ -35,7 +36,16 @@ export function seedCommandCodeSubmittedPromptStatus( try { state.setAgentStatus( paneKey, - { state: 'working', prompt, agentType: 'command-code' }, + { + state: 'working', + prompt, + agentType: 'command-code', + observation: rendererAgentStatusObservations.observe(paneKey, { + origin: 'process', + observedAt: Date.now(), + kind: 'transition' + }) + }, undefined, undefined, routing diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 5dc076afc1b..eedd1e9fb32 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -426,7 +426,9 @@ describe('launchAgentBackgroundSession', () => { { state: 'working', prompt: 'check the status spinner', - agentType: 'command-code' + agentType: 'command-code', + // Why: Orca launched this hidden session, so the seed predates any provider signal (STA-4293). + observation: expect.objectContaining({ origin: 'launch', kind: 'transition' }) }, undefined, undefined, diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index f65204d6558..6fd30efde21 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -249,9 +249,10 @@ export async function launchAgentBackgroundSession( // hidden prompt launches so sidebar/activity surfaces do not stay idle. const routing = agentStatusConsumer.resolveRouting() if (routing) { + const observation = agentStatusConsumer.observeLaunchIngress() store.setAgentStatus( paneKey, - { state: 'working', prompt: trimmedPrompt, agentType: agent }, + { state: 'working', prompt: trimmedPrompt, agentType: agent, observation }, undefined, undefined, routing, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 8515379d418..5f9ce256885 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -716,7 +716,9 @@ describe('launchAgentInNewTab', () => { { state: 'working', prompt: 'large generated prompt', - agentType: 'command-code' + agentType: 'command-code', + // Why: seeded from Orca's own prompt delivery, not a provider hook (STA-4293). + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) }, undefined, undefined, diff --git a/src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts b/src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts new file mode 100644 index 00000000000..59a218b2c9b --- /dev/null +++ b/src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { AgentStatusObservation } from '../../../shared/agent-status-observation' +import { buildWorktreeAgentRows } from '@/components/sidebar/worktree-agent-rows' +import { TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID } from '@/components/sidebar/worktree-title-derived-agent-rows' + +const { setAgentStatusMock } = vi.hoisted(() => ({ setAgentStatusMock: vi.fn() })) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ setAgentStatus: setAgentStatusMock }) } +})) +vi.mock('./agent-status-connection-ownership', () => ({ + resolveLiveAgentStatusConnectionRouting: () => ({ connectionId: null }) +})) + +const LEAF_ID = '77777777-7777-4777-8777-777777777777' + +function makeTab(id: string): TerminalTab { + return { + id, + worktreeId: 'wt-1', + ptyId: null, + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeSingleLayout(): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null + } +} + +function titleRowObservation(now: number): AgentStatusObservation { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Codex' } }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout() }, + now + }) + const observation = rows[0]?.entry.observation + if (!observation) { + throw new Error('expected a title-derived row carrying an observation') + } + return observation +} + +describe('renderer-side observation origins', () => { + it('tags a title-derived row as title-origin, not as a hook row', () => { + // Why: this is the tag that makes the row's own contradiction findable — the entry it + // fabricates reports `working` for a pane whose row state is idle. + expect(titleRowObservation(2_000)).toEqual({ + origin: 'title', + authorityId: TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID, + incarnation: 0, + revision: 2_000, + observedAt: 2_000, + kind: 'snapshot' + }) + }) + + it('keeps the title-row stamp deterministic in `now` so re-deriving it does not churn', () => { + // Why: these rows are recomputed every render; a counter here would mint a new revision + // per frame and invalidate memoization that compares rows. + expect(titleRowObservation(2_000)).toEqual(titleRowObservation(2_000)) + expect(titleRowObservation(3_000).revision).toBeGreaterThan(titleRowObservation(2_000).revision) + }) + + it('tags renderer-parsed OSC 9999 rows as osc-origin under the renderer authority', async () => { + setAgentStatusMock.mockReset() + const { createBackgroundAgentStatusConsumer } = + await import('./background-agent-status-consumer') + const paneKey = 'tab-osc:99999999-9999-4999-8999-999999999999' + const consumer = createBackgroundAgentStatusConsumer({ + paneKey, + launchToken: 'launch-1', + // Why: main parses OSC for local/SSH PTYs; only when it does not is the renderer the authority. + mainOwnsAgentStatusWrites: false, + expectedConnectionId: null, + runtimeEnvironmentId: 'env-1', + getPtyId: () => 'pty-1' + }) + + consumer.consume(`\x1b]9999;{"state":"working","prompt":"remote turn"}\x07`) + consumer.consume(`\x1b]9999;{"state":"done","prompt":"remote turn"}\x07`) + + expect(setAgentStatusMock).toHaveBeenCalledTimes(2) + const observations = setAgentStatusMock.mock.calls.map( + (call) => (call[1] as { observation?: AgentStatusObservation }).observation + ) + for (const observation of observations) { + expect(observation).toMatchObject({ + origin: 'osc', + kind: 'snapshot', + authorityId: expect.stringMatching(/^renderer:/) + }) + } + expect(observations[1]!.revision).toBeGreaterThan(observations[0]!.revision) + }) +}) diff --git a/src/renderer/src/lib/renderer-agent-status-observations.ts b/src/renderer/src/lib/renderer-agent-status-observations.ts new file mode 100644 index 00000000000..5cac81ce491 --- /dev/null +++ b/src/renderer/src/lib/renderer-agent-status-observations.ts @@ -0,0 +1,17 @@ +import { + AgentStatusObservationSequencer, + createAgentStatusAuthorityId +} from '../../../shared/agent-status-observation' + +/** + * The renderer's sequencer for status rows it writes itself: remote-runtime OSC bytes it + * parses locally, launch seeds, and Command Code output seeds. Deliberately a SECOND + * authority — rows main sequenced arrive already stamped and keep main's id, so the two + * clocks that today share one `updatedAt` comparison become visible instead of implied. + * + * Nothing reads it yet (STA-4293). See shared/agent-status-observation.ts for the rule that + * ids from different authorities are incomparable, not merely older. + */ +export const rendererAgentStatusObservations = new AgentStatusObservationSequencer( + createAgentStatusAuthorityId('renderer') +) diff --git a/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts new file mode 100644 index 00000000000..f0b21eca930 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { buildWorktreeAgentRows } from '@/components/sidebar/worktree-agent-rows' +import { getAgentDotState } from '@/components/sidebar/worktree-card-agent-summary' +import { resolveAttention } from '@/components/sidebar/smart-attention' +import { createTestStore, makeTab } from './store-test-helpers' + +// The safety argument for STA-4293 step 1 in one file: an observation-stamped row and an +// unstamped row must be indistinguishable to every consumer that reads status today. + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const OBSERVATION: AgentStatusObservation = { + origin: 'hook', + authorityId: 'main-agent-hooks:test', + incarnation: 3, + revision: 17, + observedAt: 1_700_000_000_000, + boundary: true, + kind: 'transition' +} + +const STATES: AgentStatusState[] = ['working', 'blocked', 'waiting', 'done'] + +function withoutObservation(entry: AgentStatusEntry): Omit { + const { observation: _observation, ...rest } = entry + return rest +} + +function applyStatus( + state: AgentStatusState, + observation: AgentStatusObservation | undefined +): { + entry: AgentStatusEntry + agentStatusEpoch: number + sortEpoch: number +} { + const store = createTestStore() + store.getState().setAgentStatus(PANE_KEY, { + state: 'working', + prompt: 'first turn', + agentType: 'claude', + ...(observation ? { observation } : {}) + }) + store.getState().setAgentStatus(PANE_KEY, { + state, + prompt: 'first turn', + agentType: 'claude', + lastAssistantMessage: state === 'done' ? 'all set' : undefined, + ...(observation ? { observation } : {}) + }) + const snapshot = store.getState() + const entry = snapshot.agentStatusByPaneKey[PANE_KEY] + if (!entry) { + throw new Error(`expected a live entry for ${state}`) + } + return { + entry, + agentStatusEpoch: snapshot.agentStatusEpoch, + sortEpoch: snapshot.sortEpoch + } +} + +describe('agent status observation is behavior-neutral', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(STATES)('produces an identical %s entry apart from the observation field', (state) => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + + const stamped = applyStatus(state, OBSERVATION) + vi.setSystemTime(1_700_000_000_000) + const unstamped = applyStatus(state, undefined) + + expect(withoutObservation(stamped.entry)).toEqual(withoutObservation(unstamped.entry)) + expect(stamped.entry.observation).toEqual(OBSERVATION) + expect(unstamped.entry.observation).toBeUndefined() + // Why: an extra epoch tick is an extra render across every aggregate consumer — that + // would be a behavior change even though no rendered value differs. + expect(stamped.agentStatusEpoch).toBe(unstamped.agentStatusEpoch) + expect(stamped.sortEpoch).toBe(unstamped.sortEpoch) + }) + + it.each(STATES)('resolves %s identically for freshness, rows, dot and attention', (state) => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + const stamped = applyStatus(state, OBSERVATION).entry + vi.setSystemTime(1_700_000_000_000) + const unstamped = applyStatus(state, undefined).entry + vi.useRealTimers() + + const now = 1_700_000_000_000 + const tabs = [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + + for (const at of [now, now + AGENT_STATUS_STALE_AFTER_MS + 1]) { + expect(isExplicitAgentStatusFresh(stamped, at, AGENT_STATUS_STALE_AFTER_MS)).toBe( + isExplicitAgentStatusFresh(unstamped, at, AGENT_STATUS_STALE_AFTER_MS) + ) + + const stampedRows = buildWorktreeAgentRows({ + tabs, + entries: [stamped], + retained: [], + now: at + }) + const unstampedRows = buildWorktreeAgentRows({ + tabs, + entries: [unstamped], + retained: [], + now: at + }) + // Why: an empty-vs-empty comparison would pass without proving anything. + expect(stampedRows).toHaveLength(1) + expect(stampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) }))).toEqual( + unstampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) })) + ) + expect(stampedRows.map(getAgentDotState)).toEqual(unstampedRows.map(getAgentDotState)) + + expect(resolveAttention([{ kind: 'hook', entry: stamped }], at)).toEqual( + resolveAttention([{ kind: 'hook', entry: unstamped }], at) + ) + } + }) + + it('leaves an unstamped entry resolving exactly as it does today', () => { + // Why: old hosts, persisted rehydration, title-derived rows and subagent rows all reach + // consumers with no observation at all; that path must not have moved. + const now = 1_700_000_000_000 + const entry: AgentStatusEntry = { + paneKey: PANE_KEY, + state: 'blocked', + prompt: 'needs approval', + updatedAt: now, + stateStartedAt: now, + stateHistory: [], + agentType: 'codex' + } + + expect(isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)).toBe(true) + expect(resolveAttention([{ kind: 'hook', entry }], now)).toMatchObject({ cls: 1 }) + const rows = buildWorktreeAgentRows({ + tabs: [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })], + entries: [entry], + retained: [], + now + }) + expect(rows).toHaveLength(1) + expect(rows[0].entry.observation).toBeUndefined() + expect(getAgentDotState(rows[0])).toBe('blocked') + }) + + it('does not carry a previous observation onto an unstamped write', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus(PANE_KEY, { + state: 'working', + prompt: 'p', + agentType: 'claude', + observation: OBSERVATION + }) + store.getState().setAgentStatus(PANE_KEY, { state: 'done', prompt: 'p', agentType: 'claude' }) + + // Why: inheriting it would let a stale authority claim ordering over a row it never observed. + expect(store.getState().agentStatusByPaneKey[PANE_KEY]?.observation).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index e3b27077bf4..cb86af68d38 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -12,6 +12,8 @@ import { type MigrationUnsupportedPtyEntry, type ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' +import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations' import { agentProviderSessionsEqual, getAgentResumeArgv, @@ -112,6 +114,9 @@ export type AgentStatusPayload = ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string restoredUnconfirmed?: boolean + /** Ingress provenance for this write (STA-4293). Read by nothing yet; a caller that omits + * it produces exactly the entry it produces today. See agent-status-observation.ts. */ + observation?: AgentStatusObservation } export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number } @@ -1481,6 +1486,9 @@ export const createAgentStatusSlice: StateCreator { const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) @@ -1596,6 +1604,10 @@ export const createAgentStatusSlice: StateCreator ({ @@ -2194,6 +2206,9 @@ export const createAgentStatusSlice: StateCreator { + return sequencer.observe(paneKey, { origin: 'hook', observedAt: 1_000 }) +} + +describe('AgentStatusObservationSequencer', () => { + it('advances revision strictly for every observation of a pane', () => { + const sequencer = new AgentStatusObservationSequencer('authority-1') + + const first = observe(sequencer, PANE_A) + const second = observe(sequencer, PANE_A) + const third = observe(sequencer, PANE_A) + + expect(first.revision).toBeLessThan(second.revision) + expect(second.revision).toBeLessThan(third.revision) + expect(first.authorityId).toBe('authority-1') + }) + + it('keeps each pane strictly increasing while panes interleave', () => { + const sequencer = new AgentStatusObservationSequencer('authority-1') + + const a1 = observe(sequencer, PANE_A) + const b1 = observe(sequencer, PANE_B) + const a2 = observe(sequencer, PANE_A) + const b2 = observe(sequencer, PANE_B) + + expect(a2.revision).toBeGreaterThan(a1.revision) + expect(b2.revision).toBeGreaterThan(b1.revision) + }) + + it('bumps incarnation on rebind and leaves it alone otherwise', () => { + const sequencer = new AgentStatusObservationSequencer('authority-1') + + const before = observe(sequencer, PANE_A) + const stillBefore = observe(sequencer, PANE_A) + sequencer.rebind(PANE_A) + const after = observe(sequencer, PANE_A) + + expect(stillBefore.incarnation).toBe(before.incarnation) + expect(after.incarnation).toBeGreaterThan(before.incarnation) + // Why: rebind marks a new session behind the key; it must not rewind the order of what came before. + expect(after.revision).toBeGreaterThan(stillBefore.revision) + }) + + it('rebinding one pane does not move another pane already being tracked', () => { + const sequencer = new AgentStatusObservationSequencer('authority-1') + + const otherBefore = observe(sequencer, PANE_B) + sequencer.rebind(PANE_A) + const otherAfter = observe(sequencer, PANE_B) + + expect(otherAfter.incarnation).toBe(otherBefore.incarnation) + }) + + it('never lowers a pane incarnation after its state is forgotten', () => { + const sequencer = new AgentStatusObservationSequencer('authority-1') + + observe(sequencer, PANE_A) + sequencer.rebind(PANE_A) + const rebound = observe(sequencer, PANE_A) + sequencer.forget(PANE_A) + const reobserved = observe(sequencer, PANE_A) + + expect(reobserved.incarnation).toBeGreaterThanOrEqual(rebound.incarnation) + expect(reobserved.revision).toBeGreaterThan(rebound.revision) + }) + + it('keeps revision increasing for a pane whose per-pane state was evicted by the cap', () => { + const sequencer = new AgentStatusObservationSequencer('authority-1') + + const first = observe(sequencer, PANE_A) + // Why: overflow the bounded per-pane map so PANE_A's entry is definitely evicted. + for (let i = 0; i < 2_000; i++) { + observe(sequencer, `tab-flood:${i}`) + } + const afterEviction = observe(sequencer, PANE_A) + + expect(afterEviction.revision).toBeGreaterThan(first.revision) + expect(afterEviction.incarnation).toBeGreaterThanOrEqual(first.incarnation) + }) + + it('stamps the requested facets and omits absent optional ones', () => { + const sequencer = new AgentStatusObservationSequencer('authority-1') + + const boundary = sequencer.observe(PANE_A, { + origin: 'osc', + observedAt: 42, + boundary: true, + kind: 'snapshot' + }) + const plain = sequencer.observe(PANE_A, { origin: 'title', observedAt: 43, boundary: false }) + + expect(boundary).toMatchObject({ + origin: 'osc', + observedAt: 42, + boundary: true, + kind: 'snapshot' + }) + expect(plain.origin).toBe('title') + expect('boundary' in plain).toBe(false) + expect('kind' in plain).toBe(false) + }) + + it('gives each sequencer instance a distinct authority id', () => { + // Why: revision counters live in memory, so a restarted authority must not be + // comparable with the observations it emitted before. + const first = createAgentStatusAuthorityId('main-agent-hooks') + const second = createAgentStatusAuthorityId('main-agent-hooks') + + expect(first).not.toBe(second) + expect(first.startsWith('main-agent-hooks:')).toBe(true) + }) +}) diff --git a/src/shared/agent-status-observation.ts b/src/shared/agent-status-observation.ts new file mode 100644 index 00000000000..0eeb60e4bb3 --- /dev/null +++ b/src/shared/agent-status-observation.ts @@ -0,0 +1,172 @@ +// ─── Observation provenance for agent status (STA-4293, step 1) ───────────── +// An AgentStatusEntry today cannot say who observed it, on whose clock, or in what +// order relative to the pane's other observations. Hook rows carry main's +// watermark-forced monotonic clock while byte-derived rows carry the renderer's +// Date.now(), and both are arbitrated by one strict `<` on updatedAt. This facet +// records the missing facts at every ingress. +// +// NOTHING READS IT YET. It is stamped so consumers can be migrated one at a time. + +/** Where the evidence for a status row came from — the ingress, not the transport. + * A hook event relayed over SSH is still `hook`; the relay is a carrier. */ +export const AGENT_STATUS_OBSERVATION_ORIGINS = [ + /** Provider hook event (loopback HTTP or relayed), run through a provider normalizer. */ + 'hook', + /** OSC 9999 structured payload parsed out of PTY bytes. Canonical payload, no provider normalizer. */ + 'osc', + /** Inferred from a terminal title. The weakest evidence Orca acts on. */ + 'title', + /** Derived from the pane's own process/output evidence (e.g. Command Code output seeds). */ + 'process', + /** Seeded when Orca launched the agent itself, before any provider signal. */ + 'launch', + /** Stamped by orchestration dispatch rather than by the agent. */ + 'orchestration' +] as const +export type AgentStatusObservationOrigin = (typeof AGENT_STATUS_OBSERVATION_ORIGINS)[number] + +/** + * What the observation claims, independent of `state`. + * - `transition` — an event asserting the pane just changed state. + * - `snapshot` — a repaint/replay of the state already believed true (OSC repaints, relay + * replay, title-derived rows re-derived every render). Never proof of a new turn. + * - `identity-only` — a resume-identity refresh whose status-shaped fields are transport + * placeholders (`providerSessionOnly`, agent-status-types.ts). PR #14657 had to + * special-case exactly this; the facet exists so the next consumer need not rediscover it. + */ +export type AgentStatusObservationKind = 'transition' | 'snapshot' | 'identity-only' + +export type AgentStatusObservation = { + origin: AgentStatusObservationOrigin + /** Which component sequenced this observation. Opaque; see the ordering rule below. */ + authorityId: string + /** Bumped when the pane/PTY behind this key is rebound. Non-decreasing per pane. */ + incarnation: number + /** Monotonic per pane within one authority. Compare it; never count with it (values are shared + * across the authority's panes, so consecutive observations of one pane leave gaps). */ + revision: number + /** The AUTHORITY's clock when it sequenced this observation — not the reader's. */ + observedAt: number + /** This observation is a user-initiated new turn. Stamped from the hook listener's + * own per-provider `isNewTurnEvent`, never from a fresh list of event-name literals: + * the retired-pane gate (server.ts) matched two raw literals and stranded providers + * whose boundary event is named anything else — the defect PR #14626 fixed one instance of. */ + boundary?: true + kind?: AgentStatusObservationKind +} + +/** The observation facet, mixed into every row shape that carries one. Optional and read by + * nothing yet (STA-4293): rows from old hosts, persisted rehydration, and any ingress not yet + * stamped carry none, so consumers must keep working without it. Deliberately NOT mixed into + * `AgentStatusPayload` — the ingress stamps it and it is never read back out of the reported + * body, so a hook or OSC writer cannot declare its own provenance. */ +export type WithAgentStatusObservation = { observation?: AgentStatusObservation } + +// ─── THE ORDERING RULE ────────────────────────────────────────────────────── +// `(authorityId, incarnation, revision)` is a total order ONLY within one authorityId. +// A different authorityId means "incomparable", not "older" — the id is regenerated per +// authority instance, so a restarted main, a second machine, and a renderer that parsed +// bytes itself all sequence into disjoint spaces. Consumers must fall back to today's +// timestamp rule across authorities, never mix the two orders. +// +// ─── THE DECAY RULE (neither original review caught this) ─────────────────── +// Staleness must be computed against the SAME authority clock that stamped `observedAt`, +// or replicas must decay on LOCAL RECEIPT time instead. +// +// `observedAt` is the authority's wall clock. Today `isExplicitAgentStatusFresh` +// (renderer/src/lib/pane-agent-evidence.ts) computes `rendererNow - entry.updatedAt`, and +// for a MIRRORED REMOTE entry `updatedAt` is the HOST's clock. A host running minutes fast +// makes every remote row look permanently fresh; a host running slow makes them decay on +// arrival. Declaring `observedAt` display-only does NOT fix that — the skew is in the +// subtraction, not in the tiebreak. A replica must either receive the authority's own +// freshness verdict, or stamp its own receipt time and decay against that. +// +// This PR does not fix it. It records the contract at the type so the PR that moves the +// first consumer has something to be correct against. + +/** Bounds the per-pane incarnation map. Panes are created for the life of the process; + * eviction is safe because `incarnation` is floored by an authority-wide counter (below). */ +const OBSERVATION_PANE_STATE_MAX = 1024 + +/** + * Assigns ordering metadata for one authority (one main process, one renderer). + * + * `revision` is a single authority-wide counter rather than one per pane: it is then + * strictly increasing for every pane by construction, including a pane whose per-pane + * state was evicted, so a future consumer can never read a restarted counter as "older". + * + * `incarnation` is per pane, floored by an authority-wide counter that `rebind` advances. + * A pane re-observed after eviction therefore resumes at a value no lower than its last. + */ +export class AgentStatusObservationSequencer { + private revision = 0 + private incarnationFloor = 0 + private readonly incarnationByPaneKey = new Map() + + constructor(private readonly authorityId: string) {} + + getAuthorityId(): string { + return this.authorityId + } + + observe( + paneKey: string, + args: { + origin: AgentStatusObservationOrigin + observedAt: number + boundary?: boolean + kind?: AgentStatusObservationKind + } + ): AgentStatusObservation { + this.revision += 1 + return { + origin: args.origin, + authorityId: this.authorityId, + incarnation: this.resolveIncarnation(paneKey), + revision: this.revision, + observedAt: args.observedAt, + ...(args.boundary ? { boundary: true as const } : {}), + ...(args.kind ? { kind: args.kind } : {}) + } + } + + /** The pane's PTY was rebound (relaunch, reattach, pane reuse): later observations + * describe a different session behind the same key. */ + rebind(paneKey: string): void { + this.incarnationFloor += 1 + this.incarnationByPaneKey.delete(paneKey) + this.setIncarnation(paneKey, this.incarnationFloor) + } + + /** Pane is gone; drop its state. Re-observation after this reads the current floor. */ + forget(paneKey: string): void { + this.incarnationByPaneKey.delete(paneKey) + } + + private resolveIncarnation(paneKey: string): number { + const known = this.incarnationByPaneKey.get(paneKey) + if (known !== undefined) { + return known + } + this.setIncarnation(paneKey, this.incarnationFloor) + return this.incarnationFloor + } + + private setIncarnation(paneKey: string, incarnation: number): void { + this.incarnationByPaneKey.set(paneKey, incarnation) + while (this.incarnationByPaneKey.size > OBSERVATION_PANE_STATE_MAX) { + const oldest = this.incarnationByPaneKey.keys().next().value + if (oldest === undefined) { + return + } + this.incarnationByPaneKey.delete(oldest) + } + } +} + +/** Per-instance authority id. Regenerated every process start on purpose: a restarted + * authority's revision counter starts over, so its observations must not be comparable + * with the ones it emitted before (including any rehydrated from disk). */ +export function createAgentStatusAuthorityId(role: string): string { + return `${role}:${globalThis.crypto.randomUUID()}` +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index c1e3db6e606..6df1c6568f7 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -3,6 +3,7 @@ // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { WithAgentStatusObservation } from './agent-status-observation' import { normalizeInteractivePromptField, normalizeOptionalField, @@ -151,7 +152,7 @@ export type AgentStatusEntry = { * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ restoredUnconfirmed?: boolean -} +} & WithAgentStatusObservation export type MigrationUnsupportedPtyEntry = { ptyId: string @@ -252,7 +253,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { promptInteractionKey?: string /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ restoredUnconfirmed?: boolean -} +} & WithAgentStatusObservation /** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ export type AgentStatusClearIpcPayload =