mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* refactor(agent-status): publish structured sessions into the hook server store Structured (native chat) sessions have no PTY and no hook script, so their status never reached the hook server's store; #19217 gave `worktree ps` its own adapter over the structured feed instead. The feed now writes every projection into that store through a status sink the runtime wires, drops the row when the host closes the session, and `worktree ps` reads the one snapshot like every other agent. Rows carry a `structuredHost` marker and the journal clock; they are never persisted to last-status.json, and the main process does not forward them to the renderer yet, whose feed bridge still owns them until it is retired. Design and the two follow-ups: docs/reference/agent-status-store.md. * chore: drop stray @pnpm/exe lockfile entry An unrelated local pnpm run added @pnpm/exe as a packageManagerDependency with no package.json change, so CI's --frozen-lockfile install failed before any job ran. * docs(agent-status): describe the step that actually landed The design record claimed PR 1 deletes RuntimeAgentRowStore, drops the retained-versus-hook reconciliation, stamps terminalHandle on OSC rows, and tags rows with a source field of 'structured-host'. None of that is true of the shipped code: the retained store and its reconciliation are still in place, and the row field is structuredHost: 'held' | 'owned'. AGENTS.md points every future contributor here before they touch agent status, so split the roadmap into the 1a that landed and the 1b that has not, and name the fields the code actually writes. * fix(agent-status): pair session removal with the status-row forget A session dropped from the host's map without an explicit forget left its row in the store forever: `structuredHostOwned` bypasses the staleness check, so a failed re-attach (the Claude rewind path reaches one) stranded a permanently working agent in `worktree ps` and on mobile with no UI able to clear it. Deletion and forget are now one operation both callers route through. * fix(agent-status): give orcad the store worktree ps reads from `orcad` constructed its runtime with neither `getAgentStatusSnapshot` nor `structuredAgentStatusSink`, so once `worktree ps` sourced rows only from that snapshot the headless host published nowhere and listed nothing. The hook server's store is a module singleton whose import tree never reaches Electron, and its file paths come from `start()`, which orcad never calls. * fix(agent-status): drop a structured row without a renderer clear `dropStructuredStatus` went through `clearPaneState`, which fans a pane clear out to the renderer for a pane key the renderer's own feed bridge still writes - so 'exactly one writer per pane key' held for writes and not for deletes. `dropStatusEntry` routes through the status-drop tap instead, and skips the resume-identity remnant: a structured session has no pane to resume into, and every null-status publish would otherwise re-mint one. * test(agent-status): pin both half-migration structured-row filters Neither the `agentStatus:getSnapshot` filter nor the main-window listener's had a single assertion, so deleting either — the first step of PR 2 — was green everywhere. Also covers the perf skip and the drop's lack of a renderer clear. * docs(agent-status): correct three statements this PR made false The sink JSDoc claimed only tests construct a host without one; `orcad` did. The doc argued a structured row needs no tab mirror 'because headless serve has no renderer', reasoning about exactly the topology the wiring had not reached. The deleted runtime adapter's warning that the pane key must be the DERIVED one - never a bearer handle or minted worker key - was lost with it. * test(agent-status): declare orcad in the hook-row producer census Wiring the hook store into the orcad runtime added a production site that hands hook rows to a consumer, which the census ratchet pins deliberately. --------- Co-authored-by: Merge Sim <sim@local>
200 lines
10 KiB
TypeScript
200 lines
10 KiB
TypeScript
// ─── 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',
|
|
/** Projected by the structured session host from a session's journal; no PTY, no hook. */
|
|
'structured'
|
|
] 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 }
|
|
|
|
/** REPLICA-local receipt clock for a row this renderer mirrored from another host's snapshot.
|
|
* Stamped by `buildMirroredAgentStatusPatch` when the authority's observation clock advances,
|
|
* and carried forward when it does not (a repeated snapshot restates evidence already seen;
|
|
* it is not a new observation). `agentStatusEvidenceObservedAt` prefers it, so a mirrored row
|
|
* decays against THIS machine's clock instead of subtracting the host's — see THE DECAY RULE.
|
|
* Absent on every locally observed row. Never sent over IPC or persisted. */
|
|
export type MirroredEvidenceReceipt = { mirroredEvidenceReceivedAt?: number }
|
|
|
|
/** Renderer-local count of ACCEPTED status writes this pane's row has taken. Incremented only by
|
|
* the store's accept branch, off the row it replaces, and carried through by every field-level
|
|
* rewrite — so it answers "did the pane report again?", which `updatedAt` cannot, because the
|
|
* accept rule admits equal timestamps. Lives on the row rather than in a side table so no
|
|
* teardown path can reset it out from under a reader (STA-4612). Never sent over IPC or
|
|
* persisted to last-status.json.
|
|
*
|
|
* Declared here beside the observation facet because both are per-write facets mixed into
|
|
* `AgentStatusEntry` rather than fields a reporter supplies. */
|
|
export type AgentStatusRowFacets = WithAgentStatusObservation &
|
|
MirroredEvidenceReceipt & { acceptedStatusSeq?: number }
|
|
|
|
// ─── 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. `isExplicitAgentStatusFresh`
|
|
// (renderer/src/lib/pane-agent-evidence.ts) computes `rendererNow - <observation clock>`, and
|
|
// for a MIRRORED REMOTE entry the authority's stamps are the HOST's clock. A host running
|
|
// minutes fast made every remote row look permanently fresh; a host running slow made 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.
|
|
//
|
|
// RESOLVED by the receipt stamp: `buildMirroredAgentStatusPatch` writes
|
|
// `mirroredEvidenceReceivedAt` from the replica's own clock, and
|
|
// `agentStatusEvidenceObservedAt` decays against it, so both sides of the subtraction come
|
|
// from one machine. The authority's own verdict was rejected: a published verdict is computed
|
|
// at publish time and cannot age between snapshots, so the moment the host stops publishing —
|
|
// the loss-of-contact case this whole rule exists for — the replica would hold `fresh` forever.
|
|
// The receipt stamp keeps decaying while nothing arrives, which is the required behaviour.
|
|
|
|
/** 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()}`
|
|
}
|