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