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.
This commit is contained in:
Brennan Benson
2026-08-18 00:46:41 -07:00
committed by GitHub
parent 8ea5dd80c3
commit 26bdfc0fe4
25 changed files with 1146 additions and 46 deletions
@@ -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 })
}
})
})
+83 -12
View File
@@ -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<string, number>()
// 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
+3
View File
@@ -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)
@@ -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<string, Record<number, string>> = {}
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
const EMPTY_TERMINAL_LAYOUTS: Record<string, TerminalLayoutSnapshot | undefined> = {}
@@ -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,
@@ -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
@@ -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,
@@ -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 }
@@ -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,
@@ -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,
@@ -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
+6 -1
View File
@@ -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,
@@ -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)
@@ -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 }
}
@@ -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
@@ -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,
@@ -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,
@@ -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,
@@ -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)
})
})
@@ -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')
)
@@ -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<AgentStatusEntry, 'observation'> {
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()
})
})
@@ -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<AppState, [], [], AgentStatusS
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey)
const retiredPaneKeySet = new Set(retiredPaneKeys)
for (const key of retiredPaneKeys) {
rendererAgentStatusObservations.forget(key)
}
let hadLive = false
set((s) => {
const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey)
@@ -1596,6 +1604,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
}
const from = transfer.previousOwnerPaneKey
const to = transfer.ownerPaneKey
// Why: the moved row carries the observation stamped for its OLD key; renderer-authored
// observations for the new key must sort after it, not race it.
rendererAgentStatusObservations.forget(from)
rendererAgentStatusObservations.rebind(to)
const targetTabId = getTabIdFromPaneKey(to) ?? undefined
const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined
set((s) => ({
@@ -2194,6 +2206,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
// Why: never inherited from `existing` — an unstamped write is an unstamped
// observation, not the previous one repeated.
...(payload.observation ? { observation: payload.observation } : {}),
// Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly.
interrupted: payload.interrupted,
// Why: done→done repaints (OSC 9999, reconnect snapshot replays) re-deliver a
+3 -1
View File
@@ -2474,7 +2474,9 @@ function isGrokIdleNotification(message: string | undefined): boolean {
)
}
function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
/** The per-provider answer to "is this event a user-initiated new turn?". Exported so the
* observation stamp reuses it instead of minting a second list of event-name literals. */
export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false.
switch (source) {
case 'claude':
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
AgentStatusObservationSequencer,
createAgentStatusAuthorityId
} from './agent-status-observation'
const PANE_A = 'tab-a:11111111-1111-4111-8111-111111111111'
const PANE_B = 'tab-b:22222222-2222-4222-8222-222222222222'
function observe(
sequencer: AgentStatusObservationSequencer,
paneKey: string
): ReturnType<AgentStatusObservationSequencer['observe']> {
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)
})
})
+172
View File
@@ -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<string, number>()
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()}`
}
+3 -2
View File
@@ -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 =