diff --git a/docs/api/durable.md b/docs/api/durable.md index b9ab24e7..e9942b86 100644 --- a/docs/api/durable.md +++ b/docs/api/durable.md @@ -6,6 +6,163 @@ # durable +## Classes + +### FileObserverJournal + +Durable, append-only third-person history for one concrete Runtime execution. +It consumes Runtime's existing hook stream and does not participate in execution +decisions. A broken observer therefore cannot change what an agent is allowed to do. + +The write discipline deliberately matches `FileSpawnJournal`: serialized appends, +torn-tail recovery, short-write handling, and fsync before acknowledgement. One +execution owns one journal file; higher-level pursuit aggregation joins isolated +journals by `pursuitId` instead of making independent processes share a write head. + +#### Implements + +- [`ObserverJournal`](#observerjournal) + +#### Constructors + +##### Constructor + +> **new FileObserverJournal**(`path`, `pursuitId`): [`FileObserverJournal`](#fileobserverjournal) + +###### Parameters + +###### path + +`string` + +###### pursuitId + +`string` + +###### Returns + +[`FileObserverJournal`](#fileobserverjournal) + +#### Properties + +##### path + +> `readonly` **path**: `string` + +##### pursuitId + +> `readonly` **pursuitId**: `string` + +#### Methods + +##### hooks() + +> **hooks**(): [`RuntimeHooks`](index.md#runtimehooks) + +###### Returns + +[`RuntimeHooks`](index.md#runtimehooks) + +###### Implementation of + +[`ObserverJournal`](#observerjournal).[`hooks`](#hooks-1) + +##### appendEvent() + +> **appendEvent**(`event`): `Promise`\<[`ObserverRecord`](#observerrecord)\> + +###### Parameters + +###### event + +[`RuntimeHookEvent`](index.md#runtimehookevent) + +###### Returns + +`Promise`\<[`ObserverRecord`](#observerrecord)\> + +###### Implementation of + +[`ObserverJournal`](#observerjournal).[`appendEvent`](#appendevent) + +##### appendDecision() + +> **appendDecision**(`point`): `Promise`\<[`ObserverRecord`](#observerrecord)\> + +###### Parameters + +###### point + +[`RuntimeDecisionPoint`](index.md#runtimedecisionpoint) + +###### Returns + +`Promise`\<[`ObserverRecord`](#observerrecord)\> + +###### Implementation of + +[`ObserverJournal`](#observerjournal).[`appendDecision`](#appenddecision) + +##### read() + +> **read**(): `Promise`\ + +###### Returns + +`Promise`\ + +###### Implementation of + +[`ObserverJournal`](#observerjournal).[`read`](#read) + +*** + +### SupervisePursuitError + +A failed Runtime execution whose complete third-person projection was retained. + +#### Extends + +- `Error` + +#### Constructors + +##### Constructor + +> **new SupervisePursuitError**(`cause`, `pursuit`, `observerPath`): [`SupervisePursuitError`](#supervisepursuiterror) + +###### Parameters + +###### cause + +`unknown` + +###### pursuit + +[`PursuitProjection`](#pursuitprojection) + +###### observerPath + +`string` + +###### Returns + +[`SupervisePursuitError`](#supervisepursuiterror) + +###### Overrides + +`Error.constructor` + +#### Properties + +##### pursuit + +> `readonly` **pursuit**: [`PursuitProjection`](#pursuitprojection) + +##### observerPath + +> `readonly` **observerPath**: `string` + ## Interfaces ### ChatStreamEvent @@ -271,132 +428,1337 @@ Content type for the response. *** -### DurableCoordinationStreamIdentity +### ObserverRecord + +One immutable record in the observer plane. `sequence` is journal order, not +execution order; causal/runtime order remains available on the underlying event. +`previousDigest` + `digest` make deletion, reordering, or mutation detectable. #### Properties -##### runId +##### schemaVersion -> `readonly` **runId**: `string` +> `readonly` **schemaVersion**: `1` -##### ownerIds +##### pursuitId -> `readonly` **ownerIds**: readonly `string`[] +> `readonly` **pursuitId**: `string` -Exact owner ids present in the side-log, sorted for deterministic display. +##### sequence -##### unscopedRecords +> `readonly` **sequence**: `number` -> `readonly` **unscopedRecords**: `number` +##### kind -Records written before owner-scoped coordination identities were introduced. +> `readonly` **kind**: [`ObserverRecordKind`](#observerrecordkind) -##### recordCount +##### observedAt -> `readonly` **recordCount**: `number` +> `readonly` **observedAt**: `number` + +##### previousDigest? + +> `readonly` `optional` **previousDigest?**: `string` + +##### event? + +> `readonly` `optional` **event?**: [`RuntimeHookEvent`](index.md#runtimehookevent)\<`unknown`\> + +##### decision? + +> `readonly` `optional` **decision?**: [`RuntimeDecisionPoint`](index.md#runtimedecisionpoint) + +##### digest + +> `readonly` **digest**: `string` *** -### DurableSupervisionDiscovery +### ObserverJournal -Identities discoverable from one `supervise({ runDir })` directory without -already knowing the root node or coordination run id stored inside it. +#### Methods + +##### appendEvent() + +> **appendEvent**(`event`): `Promise`\<[`ObserverRecord`](#observerrecord)\> + +###### Parameters + +###### event + +[`RuntimeHookEvent`](index.md#runtimehookevent) + +###### Returns + +`Promise`\<[`ObserverRecord`](#observerrecord)\> + +##### appendDecision() + +> **appendDecision**(`point`): `Promise`\<[`ObserverRecord`](#observerrecord)\> + +###### Parameters + +###### point + +[`RuntimeDecisionPoint`](index.md#runtimedecisionpoint) + +###### Returns + +`Promise`\<[`ObserverRecord`](#observerrecord)\> + +##### read() + +> **read**(): `Promise`\ + +###### Returns + +`Promise`\ + +##### hooks() + +> **hooks**(): [`RuntimeHooks`](index.md#runtimehooks) + +###### Returns + +[`RuntimeHooks`](index.md#runtimehooks) + +*** + +### PursuitRunProjection #### Properties -##### runDir +##### runId -> `readonly` **runDir**: `string` +> `readonly` **runId**: `string` -##### spawnJournalPath +##### status -> `readonly` **spawnJournalPath**: `string` +> `readonly` **status**: [`PursuitRunStatus`](#pursuitrunstatus) -##### coordinationLogPath +##### settledAt? -> `readonly` **coordinationLogPath**: `string` +> `readonly` `optional` **settledAt?**: `number` -##### roots +##### error? -> `readonly` **roots**: readonly `string`[] +> `readonly` `optional` **error?**: `string` -##### coordinationStreams +##### firstSequence -> `readonly` **coordinationStreams**: readonly [`DurableCoordinationStreamIdentity`](#durablecoordinationstreamidentity)[] +> `readonly` **firstSequence**: `number` -## Functions +##### lastSequence -### handleChatTurn() +> `readonly` **lastSequence**: `number` -> **handleChatTurn**(`input`): [`ChatTurnResult`](#chatturnresult) +##### firstObservedAt -Run one chat turn. Returns immediately with a `ReadableStream` body; -execution starts while the stream is constructed. Backend -failures surface as `error` + `session.run.failed` events. +> `readonly` **firstObservedAt**: `number` -#### Parameters +##### lastObservedAt -##### input +> `readonly` **lastObservedAt**: `number` -[`RunChatTurnInput`](#runchatturninput) +##### eventCount -#### Returns +> `readonly` **eventCount**: `number` -[`ChatTurnResult`](#chatturnresult) +##### decisionCount + +> `readonly` **decisionCount**: `number` + +##### targets + +> `readonly` **targets**: `Readonly`\<`Record`\<`string`, `number`\>\> + +##### decisions + +> `readonly` **decisions**: `Readonly`\<`Record`\<`string`, `number`\>\> *** -### deriveExecutionId() +### PursuitNodeProjection -> **deriveExecutionId**(`input`): `string` +#### Properties -Derive a stable execution id from the run identity. -The same `(projectId, sessionId, turnIndex)` tuple yields the same id. +##### id -Use the result as both `PromptOptions.executionId` and -`PromptOptions.turnId` on the first dispatch. -The execution id addresses the server-side execution for reconnect and -replay; the turn id makes a repeated dispatch idempotent. -An execution id alone does not make a repeated POST idempotent. +> `readonly` **id**: `string` -Format is readable, not hashed: operators grepping orchestrator logs -for `gtm-agent:thread-abc:3` find the run without translating an -opaque id. Components are URL-encoded so delimiters inside caller ids -cannot collapse distinct tuples. The final id is limited to the -orchestrator replay route's 256-byte maximum. Execution ids are not a -secrecy boundary. +##### parentId? -Wire integration: - - Initial dispatch: pass the result as `executionId` and `turnId`. - - Stream replay: pass it as `executionId` with `lastEventId`. +> `readonly` `optional` **parentId?**: `string` -#### Parameters +##### runId -##### input +> `readonly` **runId**: `string` -###### projectId +Node ids are scoped to this concrete Runtime tree; `(runId,id)` is identity. -`string` +##### label? -###### sessionId +> `readonly` `optional` **label?**: `string` -`string` +##### runtime? -###### turnIndex +> `readonly` `optional` **runtime?**: `string` -`number` +##### depth? -#### Returns +> `readonly` `optional` **depth?**: `number` -`string` +##### assignmentId? -#### Throws +> `readonly` `optional` **assignmentId?**: `string` -`TypeError` when either string id is blank. +##### identity? -#### Throws +> `readonly` `optional` **identity?**: `unknown` -`RangeError` when `turnIndex` is invalid or the result exceeds 256 bytes. +##### budget? + +> `readonly` `optional` **budget?**: `unknown` + +##### status + +> `readonly` **status**: [`PursuitNodeStatus`](#pursuitnodestatus) + +##### settledAt? + +> `readonly` `optional` **settledAt?**: `number` + +##### spent? + +> `readonly` `optional` **spent?**: `unknown` + +##### outRef? + +> `readonly` `optional` **outRef?**: `string` + +##### score? + +> `readonly` `optional` **score?**: `number` + +##### valid? + +> `readonly` `optional` **valid?**: `boolean` + +##### reason? + +> `readonly` `optional` **reason?**: `string` + +##### infra? + +> `readonly` `optional` **infra?**: `boolean` + +##### wait? + +> `readonly` `optional` **wait?**: `unknown` + +##### firstSequence + +> `readonly` **firstSequence**: `number` + +##### lastSequence + +> `readonly` **lastSequence**: `number` + +##### firstObservedAt + +> `readonly` **firstObservedAt**: `number` + +##### lastObservedAt + +> `readonly` **lastObservedAt**: `number` + +##### eventCount + +> `readonly` **eventCount**: `number` + +*** + +### PursuitProjection + +#### Properties + +##### pursuitId + +> `readonly` **pursuitId**: `string` + +##### sequence + +> `readonly` **sequence**: `number` + +Number of records in this concrete execution journal. + +##### chainTip + +> `readonly` **chainTip**: `string` + +Digest-chain tip for this concrete execution journal. + +##### firstObservedAt + +> `readonly` **firstObservedAt**: `number` + +##### lastObservedAt + +> `readonly` **lastObservedAt**: `number` + +##### runs + +> `readonly` **runs**: readonly [`PursuitRunProjection`](#pursuitrunprojection)[] + +##### nodes + +> `readonly` **nodes**: readonly [`PursuitNodeProjection`](#pursuitnodeprojection)[] + +##### eventCount + +> `readonly` **eventCount**: `number` + +##### decisionCount + +> `readonly` **decisionCount**: `number` + +*** + +### SupervisePursuitOptions + +#### Extends + +- [`SuperviseOptions`](runtime.md#superviseoptions) + +#### Properties + +##### pursuitId + +> `readonly` **pursuitId**: `string` + +Stable objective identity spanning concrete Runtime runs. + +##### runDir + +> `readonly` **runDir**: `string` + +One concrete Runtime execution owns one durable directory and observer journal. +A pursuit spanning several runs reuses `pursuitId` across distinct `runDir`s; +Intelligence joins those isolated projections without a shared write head. + +###### Overrides + +[`SuperviseOptions`](runtime.md#superviseoptions).[`runDir`](runtime.md#rundir-1) + +##### budget + +> `readonly` **budget**: [`Budget`](index.md#budget-4) + +The conserved compute pool for the whole run. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`budget`](runtime.md#budget-15) + +##### rootHandle? + +> `readonly` `optional` **rootHandle?**: [`RootHandle`](runtime.md#roothandle-1)\<`unknown`\> + +Caller-created live handle for observing, steering, or cancelling this root manager. Runtime +attaches it before execution and detaches it after the join barrier. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`rootHandle`](runtime.md#roothandle) + +##### signal? + +> `readonly` `optional` **signal?**: `AbortSignal` + +Caller-owned cancellation for the complete recursive run. Aborting it cascades through the +root scope and every live child, including acquisition and backend execution. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`signal`](runtime.md#signal-21) + +##### execution? + +> `readonly` `optional` **execution?**: [`AgentExecutionRef`](runtime.md#agentexecutionref) + +Trusted candidate and pursuit attribution for the root. The runtime derives profile/task +digests itself from the exact detached values it executes. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`execution`](runtime.md#execution-1) + +##### backend? + +> `readonly` `optional` **backend?**: [`ExecutorConfig`](runtime.md#executorconfig) + +WHERE workers run — derives the worker seam. Provide this OR an explicit `makeWorkerAgent`. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`backend`](runtime.md#backend-4) + +##### deliverable? + +> `readonly` `optional` **deliverable?**: `string` \| [`DeliverableSpec`](runtime.md#deliverablespec)\<`unknown`\> + +The independent completion check for backend-derived workers and direct supervisor + submissions. Strongly recommended: without it the supervisor cannot submit its own work and + backend-derived workers fall back to their own validity signal. A `string` names an entry in + `registry.deliverables`. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`deliverable`](runtime.md#deliverable-4) + +##### resolveDeliverable? + +> `readonly` `optional` **resolveDeliverable?**: (`input`) => [`DeliverableSpec`](runtime.md#deliverablespec)\<`unknown`\> \| `undefined` + +Resolve the completion check for one exact authorized backend-derived leaf. The callback runs +after spawn authorization and driver classification, receives a detached immutable context, +and may return `undefined` to use the run-wide `deliverable`. Driver profiles never call it. + +###### Parameters + +###### input + +[`AuthorizedSpawnContext`](runtime.md#authorizedspawncontext) + +###### Returns + +[`DeliverableSpec`](runtime.md#deliverablespec)\<`unknown`\> \| `undefined` + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`resolveDeliverable`](runtime.md#resolvedeliverable) + +##### registry? + +> `readonly` `optional` **registry?**: [`SuperviseRegistry`](runtime.md#superviseregistry) + +Name→value tables for the four code-valued options, so a recorded run configuration can name + them instead of carrying closures. See [SuperviseRegistry](runtime.md#superviseregistry). + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`registry`](runtime.md#registry-3) + +##### coordination? + +> `readonly` `optional` **coordination?**: [`CoordinationBinding`](runtime.md#coordinationbinding) + +Where the coordination MCP binds when the supervisor is harness-driven. Omit = an ephemeral + port on `127.0.0.1`, which an off-host root cannot reach. A non-loopback host is refused + unless `allowUnauthenticatedRemote` acknowledges that the verbs are unauthenticated. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`coordination`](runtime.md#coordination) + +##### makeWorkerAgent? + +> `readonly` `optional` **makeWorkerAgent?**: [`MakeWorkerAgent`](runtime.md#makeworkeragent) + +Override the worker seam directly (tests / advanced) instead of deriving it from `backend`. + This is caller-owned execution: profile security, spawn authorization, and recursive-driver + selection below apply only to the backend-derived worker path. `authorizeMessage` still + governs continuations sent through Runtime's coordination tools. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`makeWorkerAgent`](runtime.md#makeworkeragent-2) + +##### driverBackend? + +> `readonly` `optional` **driverBackend?**: [`ExecutorConfig`](runtime.md#executorconfig) + +Run harness-brained supervisors here. Automatic execution supports a local `bridge`; a remote + sandbox requires an explicit `driveHarness` with a reachable coordination relay or tunnel. + Defaults to `backend`; separate it when managers and workers use different services. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`driverBackend`](runtime.md#driverbackend-1) + +##### profileSecurity? + +> `readonly` `optional` **profileSecurity?**: `AgentProfileSecurityPolicy` + +Security policy applied to every manager-authored child profile before budget reservation. + The default blocks local and remote MCP, hooks, and connection grants. Pass an explicit + allowlist to grant remote MCP hosts or other author-controlled capabilities. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`profileSecurity`](runtime.md#profilesecurity) + +##### authorizeSpawn? + +> `readonly` `optional` **authorizeSpawn?**: (`input`) => [`AuthorizedSpawn`](runtime.md#authorizedspawn) + +Product authority over one complete manager-authored spawn. The callback sees the detached, + immutable profile, task, budget, label, and key together, so approving a profile cannot + authorize a different task. Return the exact allowed profile (which may be narrowed) plus + trusted candidate/pursuit attribution, or throw to refuse the whole spawn before reservation. + +###### Parameters + +###### input + +###### profile + +`AgentProfile` + +###### parent + +`AgentProfile` + +###### parentIdentity + +[`NodeExecutionIdentity`](runtime.md#nodeexecutionidentity) + +Trusted identity of the manager authorizing this exact child. + +###### parentNodeId + +`string` + +Concrete manager node; never accepted from model-authored tool arguments. + +###### assignmentId + +`string` + +Stable manager-scoped assignment, including deterministic unkeyed siblings. + +###### task + +`unknown` + +###### budget + +[`Budget`](index.md#budget-4) + +###### label + +`string` + +###### key? + +`string` + +###### depth + +`number` + +###### Returns + +[`AuthorizedSpawn`](runtime.md#authorizedspawn) + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`authorizeSpawn`](runtime.md#authorizespawn) + +##### authorizeMessage? + +> `readonly` `optional` **authorizeMessage?**: (`input`) => [`AuthorizedDownMessage`](runtime.md#authorizeddownmessage) + +Product authority over every continuation sent to a live child. When spawn authorization is +enabled, omitting this refuses steer/answer instructions instead of silently extending the +authorized task. The exact worker identity and detached bytes are recorded before delivery. + +###### Parameters + +###### input + +[`DownMessageAuthorizationInput`](runtime.md#downmessageauthorizationinput) & `object` + +###### Returns + +[`AuthorizedDownMessage`](runtime.md#authorizeddownmessage) + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`authorizeMessage`](runtime.md#authorizemessage-1) + +##### isDriverProfile? + +> `readonly` `optional` **isDriverProfile?**: (`input`) => `boolean` + +Decide whether an authorized child becomes another supervisor. By default only + `metadata.role === 'driver'` does. Products receive the same frozen post-authorization + context as `resolveDeliverable`, so trusted execution/assignment authority can override + model-authored metadata without a side channel. + +###### Parameters + +###### input + +[`AuthorizedSpawnContext`](runtime.md#authorizedspawncontext) + +###### Returns + +`boolean` + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`isDriverProfile`](runtime.md#isdriverprofile) + +##### router? + +> `readonly` `optional` **router?**: [`RouterTransportConfig`](runtime.md#routertransportconfig) + +The supervisor's router substrate (`profile.harness` omitted or `cli-base`). The profile's + model wins. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`router`](runtime.md#router-5) + +##### driveHarness? + +> `readonly` `optional` **driveHarness?**: [`DriveHarness`](runtime.md#driveharness-1) + +Run an external-harness supervisor explicitly. Required for a remote sandbox; optional as a + caller-owned override for a local bridge. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`driveHarness`](runtime.md#driveharness) + +##### driverRetry? + +> `readonly` `optional` **driverRetry?**: [`DriverRetryPolicy`](runtime.md#driverretrypolicy) + +How hard a transiently-failed EXTERNAL driver is re-entered before the run ends +`driver-failed`. A harness process SIGKILLed at a bridge timeout, a stream cut mid-turn, or an +upstream 5xx used to end a run of arbitrary length while its budget and deadline sat almost +untouched (#741). A retry re-enters the driver over the SAME scope, coordination server, and +live children; the bridge backend reattaches the harness session by its durable execution id. + +Runtime's own refusals (a validation guard, an exhausted budget, an abort, a client-side +transport status) are never retried — they were decisions. Retries stop at the budget, the +deadline, an abort, or a run of attempts that changed nothing at all. + +Omit = retry under the defaults. `{ enabled: false }` = the historical behavior where the first +driver failure ends the run. Applies to the root manager and every recursive manager under it. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`driverRetry`](runtime.md#driverretry) + +##### onDriverAttempt? + +> `readonly` `optional` **onDriverAttempt?**: (`record`) => `void` \| `Promise`\<`void`\> + +Per-attempt record for every external driver in the tree — what makes "failed after N + attempts, last cause X" visible instead of one backend's last words. + +###### Parameters + +###### record + +[`DriverAttemptRecord`](runtime.md#driverattemptrecord) + +###### Returns + +`void` \| `Promise`\<`void`\> + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`onDriverAttempt`](runtime.md#ondriverattempt) + +##### childSettleGraceMs? + +> `readonly` `optional` **childSettleGraceMs?**: `number` + +How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier +cascades the abort into them. A root that died did not make its children unhealthy: a child +mid-unit holds work already paid for, and an immediate cascade discards everything it has not +yet written. Bounded by the run's own deadline. Omit/`0` = immediate teardown. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`childSettleGraceMs`](runtime.md#childsettlegracems) + +##### resolveDriveHarness? + +> `readonly` `optional` **resolveDriveHarness?**: [`ResolveDriveHarness`](runtime.md#resolvedriveharness-1) + +Resolve one custom external-harness session per trusted manager identity. Use this instead of +`driveHarness` when recursive managers must be independently steerable. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`resolveDriveHarness`](runtime.md#resolvedriveharness) + +##### driveHarnessMaterialization? + +> `readonly` `optional` **driveHarnessMaterialization?**: [`ProfileMaterializationContract`](agent.md#profilematerializationcontract) + +Required with a custom `driveHarness` or `resolveDriveHarness`: declares which complete +AgentProfile axes that path really applies. Built-in bridge driving supplies its own +full-profile contract. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`driveHarnessMaterialization`](runtime.md#driveharnessmaterialization) + +##### resolveSupervisorTools? + +> `readonly` `optional` **resolveSupervisorTools?**: [`ResolveSupervisorTools`](runtime.md#resolvesupervisortools-1) + +Resolve product-owned tools from the exact trusted manager context. The same descriptors and +handlers are bound to router and external-harness managers; resolution happens once per node. +Each handler receives that manager scope's live cancellation signal in its trusted invocation +context, including recursive parent and root cascades. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`resolveSupervisorTools`](runtime.md#resolvesupervisortools) + +##### onCoordinationEvent? + +> `readonly` `optional` **onCoordinationEvent?**: (`context`, `eventId`, `record`) => `void` \| `Promise`\<`void`\> + +Awaited product transaction hook for every coordination record. `eventId` is stable across a +lost acknowledgement and durable restart; the record is not pull-visible until this commits. + +###### Parameters + +###### context + +[`SupervisorNodeContext`](runtime.md#supervisornodecontext) + +###### eventId + +`` `sha256:${string}` `` + +###### record + +[`BusRecord`](runtime.md#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> + +###### Returns + +`void` \| `Promise`\<`void`\> + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`onCoordinationEvent`](runtime.md#oncoordinationevent) + +##### extraTools? + +> `readonly` `optional` **extraTools?**: readonly `object`[] + +WORK tools the supervisor may call DIRECTLY — so a recursive atom can ACT (do simple work + itself) OR SPAWN (delegate when it needs parallelism), not be a pure manager. Pair with + `executeExtraTool`. Router arm only (`profile.harness` omitted or `cli-base`). + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`extraTools`](runtime.md#extratools) + +##### executeExtraTool? + +> `readonly` `optional` **executeExtraTool?**: (`name`, `args`) => `Promise`\<`string` \| `null` \| `undefined`\> + +Runs an `extraTools` call; null/undefined falls through to the coordination dispatch. + +###### Parameters + +###### name + +`string` + +###### args + +`Record`\<`string`, `unknown`\> + +###### Returns + +`Promise`\<`string` \| `null` \| `undefined`\> + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`executeExtraTool`](runtime.md#executeextratool) + +##### perWorker? + +> `readonly` `optional` **perWorker?**: [`Budget`](index.md#budget-4) + +Per-child budget reserved on each spawn. Defaults to a quarter of the pool's tokens. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`perWorker`](runtime.md#perworker-1) + +##### maxLiveWorkers? + +> `readonly` `optional` **maxLiveWorkers?**: `number` + +Hard cap on simultaneously executing spawned workers across the WHOLE recursive tree. The + root is excluded; nested drivers and leaves share one allocation, so recursion cannot multiply + the cap. Omit/`<= 0` = no cap (the conserved pool stays the only bound). + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`maxLiveWorkers`](runtime.md#maxliveworkers-4) + +##### analysts? + +> `readonly` `optional` **analysts?**: `string` \| [`AnalystRegistry`](index.md#analystregistry) + +Analyst lenses available to the driver. Required for `analyzeOnSettle`. Unset → status quo + (the driver receives settled worker outputs, no analyst findings). A `string` names an entry in + `registry.analysts`. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`analysts`](runtime.md#analysts-3) + +##### analyzeOnSettle? + +> `readonly` `optional` **analyzeOnSettle?**: readonly (`string` \| [`AnalyzeOnSettleRoute`](runtime.md#analyzeonsettleroute))[] + +Analyst kind ids run AUTOMATICALLY when a worker settles `done` — each re-enters as a `finding` + the driver pulls (`await_event`) and composes its next steer from. The self-improving UP-leg, + threaded to the driver at this level (propagate to sub-drivers via a recursive `makeWorkerAgent`). + Omit/empty = status quo (no analyst feed). Requires `analysts`. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`analyzeOnSettle`](runtime.md#analyzeonsettle) + +##### watchWorkers? + +> `readonly` `optional` **watchWorkers?**: [`WorkerWatchOptions`](runtime.md#workerwatchoptions) + +Watch every worker's LIVE tool trace with the online detector panel and raise a `finding` the +moment one loops or error-storms — so the supervisor learns it mid-run (via `await_event`) +instead of at settle. Pairs with a steerable worker: the finding is the evidence, `steer_agent` +is the correction. Requires a backend whose executor exposes a trace source (the steerable +sandbox worker and the pi wrapper do); other runtimes are simply not watched. + +Omit = off (status quo — no online watching, no extra events). + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`watchWorkers`](runtime.md#watchworkers-1) + +##### stallAfterMs? + +> `readonly` `optional` **stallAfterMs?**: `number` + +Idle time after which `observe_agent` reports a running worker as `stalled`. A derived read + at observation time — nothing is killed or retried. Omit = the runtime default. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`stallAfterMs`](runtime.md#stallafterms-3) + +##### continuityByProfile? + +> `readonly` `optional` **continuityByProfile?**: `Readonly`\<`Record`\<`string`, [`ContinuityMode`](runtime.md#continuitymode)\>\> + +Default continuity per worker PROFILE NAME: `'resume'` makes each spawn of that name after + the first re-attach to the node's most recent SETTLED worker — a NEW live worker whose spawn + context carries the prior worker's identity (`WorkerSpawnContext.resume`), which the executor + seam re-attaches with. `spawn_agent`'s per-call `continuity` argument overrides in either + direction; `runGraph` derives this from delegates-edge `continuity`. Omit = every spawn is + `'fresh'` (status quo). See `CoordinationToolsOptions.continuityByProfile` for the + refusal semantics (no-prior / while-live / with-key) and the process-local resume boundary. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`continuityByProfile`](runtime.md#continuitybyprofile) + +##### blobs? + +> `readonly` `optional` **blobs?**: [`ResultBlobStore`](runtime.md#resultblobstore) + +Worker output store. Defaults to in-memory. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`blobs`](runtime.md#blobs-4) + +##### journal? + +> `readonly` `optional` **journal?**: [`SpawnJournal`](runtime.md#spawnjournal) + +Override the spawn journal directly (advanced; `runDir` is the ordinary durable path). Pair + with `blobs` — a journal whose result payloads live in a different store cannot replay. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`journal`](runtime.md#journal-4) + +##### probes? + +> `readonly` `optional` **probes?**: `string` \| [`WaitProbeRegistry`](runtime.md#waitproberegistry) + +Predicate registry for `poll` wait-states (`Scope.wait`). A `poll` names its predicate so the + wait survives a restart; this is what the name resolves against. Unset ⇒ `poll` waits are + refused `unknown-probe` and `timer` waits still work. A `string` names an entry in + `registry.probes`. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`probes`](runtime.md#probes-2) + +##### stopRule? + +> `readonly` `optional` **stopRule?**: [`StopRule`](runtime.md#stoprule) + +PROGRESS-derived stop rule (router-brained supervisor). Ends a run that has stopped LEARNING +before it exhausts a ceiling — the answer to "a run should end because it is done or stuck, +not because it ran out". It composes with the budget guards and can never override one. + +Build it from `supervise/stop-rules`: `plateau({window, minDelta})`, +`noProgressFor({ms, settles})`, `allWorkersStalled({...})`, combined with `anyOf`/`allOf`. The +thresholds are policy and stay with you; the enforcement lives in the runtime. Omit = ceilings +only (unchanged behavior). + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`stopRule`](runtime.md#stoprule-1) + +##### onProgressStop? + +> `readonly` `optional` **onProgressStop?**: (`reason`) => `void` + +One-shot notification of WHY a `stopRule` ended the run — so a caller records the reason + instead of inferring an early stop from an unexhausted budget. + +###### Parameters + +###### reason + +`string` + +###### Returns + +`void` + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`onProgressStop`](runtime.md#onprogressstop) + +##### maxDepth? + +> `readonly` `optional` **maxDepth?**: `number` + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`maxDepth`](runtime.md#maxdepth-2) + +##### maxTurns? + +> `readonly` `optional` **maxTurns?**: `number` + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`maxTurns`](runtime.md#maxturns-2) + +##### compaction? + +> `readonly` `optional` **compaction?**: [`ToolLoopCompactionOptions`](runtime.md#toolloopcompactionoptions) + +Give the supervisor brain a chapter-lifecycle on its OWN context window (router arm only): once + its coordination transcript exceeds `thresholdTokens` it distills to a compact progress note and + continues, instead of re-billing the whole transcript every turn (the cost that makes the LLM-brain + front door lose to a dumb-Ralph respawn). The live `Scope` roster is the durable state across + chapters. Default off. `distill` defaults to a brain self-summary + the settled-worker roster. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`compaction`](runtime.md#compaction) + +##### runId? + +> `readonly` `optional` **runId?**: `string` + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`runId`](runtime.md#runid-17) + +##### now? + +> `readonly` `optional` **now?**: () => `number` + +###### Returns + +`number` + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`now`](runtime.md#now-16) + +##### allowedModels? + +> `readonly` `optional` **allowedModels?**: readonly `string`[] + +Restrict the run to this subset of models. When set, every configured model — the + supervisor router model, the profile's model, and the backend's model — must be a member, + or `supervise()` throws a `ConfigError` before any compute is spent. Unset = unrestricted. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`allowedModels`](runtime.md#allowedmodels-2) + +##### finalizer? + +> `readonly` `optional` **finalizer?**: `string` \| [`SupervisorFinalizer`](index.md#supervisorfinalizer) + +How the settled-worker ledger becomes the run's output. Default `bestDelivered` — the single + highest-scoring DELIVERED child (the exact behavior every existing caller had). Alternatives: + `collectDelivered` (every verified distinct output with provenance — a Pareto set / recorded + disagreement) or a custom `SupervisorFinalizer`. Whatever the finalizer, it operates on + structurally DELIVERED outputs only — an undelivered or invalid child stays ineligible. A + `string` names an entry in `registry.finalizers`. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`finalizer`](runtime.md#finalizer) + +##### hooks? + +> `readonly` `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) + +Lifecycle observers for the whole recursive tree (`Scope` re-seeds them into every nested + scope). Composed with the `otel` recorder below when both are set. Omit = no observers, which + is the behavior every existing caller has. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`hooks`](runtime.md#hooks-8) + +##### otel? + +> `readonly` `optional` **otel?**: `Omit`\<[`SupervisorSpanOptions`](runtime.md#supervisorspanoptions), `"runId"` \| `"now"`\> + +OPT-IN OTLP tracing: emit one span per supervised node (opened at spawn, closed at settle, +parented to its parent node's span) plus an `LLM` child span per metered driver turn, so the +tree is readable by any trace viewer instead of only by a journal parser. See `otel-spans.ts`. + +Omit and the run emits nothing, allocates no recorder, and installs no hook — telemetry is +never a default. Present with no reachable endpoint (no `exportConfig.endpoint` and no +`OTEL_EXPORTER_OTLP_ENDPOINT`) is also a no-op. The spawn journal is untouched either way: +spans are telemetry, never the replay/resume record. + +###### Inherited from + +[`SuperviseOptions`](runtime.md#superviseoptions).[`otel`](runtime.md#otel-1) + +*** + +### SupervisedPursuitResult + +#### Type Parameters + +##### Result + +`Result` + +#### Properties + +##### result + +> `readonly` **result**: `Result` + +##### pursuit + +> `readonly` **pursuit**: [`PursuitProjection`](#pursuitprojection) + +##### observerPath + +> `readonly` **observerPath**: `string` + +*** + +### DurableCoordinationStreamIdentity + +#### Properties + +##### runId + +> `readonly` **runId**: `string` + +##### ownerIds + +> `readonly` **ownerIds**: readonly `string`[] + +Exact owner ids present in the side-log, sorted for deterministic display. + +##### unscopedRecords + +> `readonly` **unscopedRecords**: `number` + +Records written before owner-scoped coordination identities were introduced. + +##### recordCount + +> `readonly` **recordCount**: `number` + +*** + +### DurableSupervisionDiscovery + +Identities discoverable from one `supervise({ runDir })` directory without +already knowing the root node or coordination run id stored inside it. + +#### Properties + +##### runDir + +> `readonly` **runDir**: `string` + +##### spawnJournalPath + +> `readonly` **spawnJournalPath**: `string` + +##### coordinationLogPath + +> `readonly` **coordinationLogPath**: `string` + +##### roots + +> `readonly` **roots**: readonly `string`[] + +##### coordinationStreams + +> `readonly` **coordinationStreams**: readonly [`DurableCoordinationStreamIdentity`](#durablecoordinationstreamidentity)[] + +## Type Aliases + +### ObserverRecordKind + +> **ObserverRecordKind** = `"event"` \| `"decision"` + +*** + +### PursuitRunStatus + +> **PursuitRunStatus** = `"running"` \| `"done"` \| `"failed"` + +*** + +### PursuitNodeStatus + +> **PursuitNodeStatus** = `"running"` \| `"done"` \| `"down"` + +## Functions + +### handleChatTurn() + +> **handleChatTurn**(`input`): [`ChatTurnResult`](#chatturnresult) + +Run one chat turn. Returns immediately with a `ReadableStream` body; +execution starts while the stream is constructed. Backend +failures surface as `error` + `session.run.failed` events. + +#### Parameters + +##### input + +[`RunChatTurnInput`](#runchatturninput) + +#### Returns + +[`ChatTurnResult`](#chatturnresult) + +*** + +### deriveExecutionId() + +> **deriveExecutionId**(`input`): `string` + +Derive a stable execution id from the run identity. +The same `(projectId, sessionId, turnIndex)` tuple yields the same id. + +Use the result as both `PromptOptions.executionId` and +`PromptOptions.turnId` on the first dispatch. +The execution id addresses the server-side execution for reconnect and +replay; the turn id makes a repeated dispatch idempotent. +An execution id alone does not make a repeated POST idempotent. + +Format is readable, not hashed: operators grepping orchestrator logs +for `gtm-agent:thread-abc:3` find the run without translating an +opaque id. Components are URL-encoded so delimiters inside caller ids +cannot collapse distinct tuples. The final id is limited to the +orchestrator replay route's 256-byte maximum. Execution ids are not a +secrecy boundary. + +Wire integration: + - Initial dispatch: pass the result as `executionId` and `turnId`. + - Stream replay: pass it as `executionId` with `lastEventId`. + +#### Parameters + +##### input + +###### projectId + +`string` + +###### sessionId + +`string` + +###### turnIndex + +`number` + +#### Returns + +`string` + +#### Throws + +`TypeError` when either string id is blank. + +#### Throws + +`RangeError` when `turnIndex` is invalid or the result exceeds 256 bytes. + +*** + +### verifyObserverRecords() + +> **verifyObserverRecords**(`records`, `pursuitId?`): readonly [`ObserverRecord`](#observerrecord)[] + +Verify identity, monotonic sequence, payload shape, and the complete digest chain. + +#### Parameters + +##### records + +readonly [`ObserverRecord`](#observerrecord)[] + +##### pursuitId? + +`string` + +#### Returns + +readonly [`ObserverRecord`](#observerrecord)[] + +*** + +### observerRecordDigest() + +> **observerRecordDigest**(`record`): `string` + +Compute the canonical SHA-256 digest for an unsigned observer record. + +#### Parameters + +##### record + +`Omit`\<[`ObserverRecord`](#observerrecord), `"digest"`\> + +#### Returns + +`string` + +*** + +### createFileObserverHooks() + +> **createFileObserverHooks**(`path`, `pursuitId`): `object` + +Build the canonical durable observer hook in one call. + +#### Parameters + +##### path + +`string` + +##### pursuitId + +`string` + +#### Returns + +`object` + +##### journal + +> `readonly` **journal**: [`FileObserverJournal`](#fileobserverjournal) + +##### hooks + +> `readonly` **hooks**: [`RuntimeHooks`](index.md#runtimehooks) + +*** + +### projectPursuit() + +> **projectPursuit**(`records`): [`PursuitProjection`](#pursuitprojection) + +Fold one append-only execution journal into a deterministic operator projection. + +This is intentionally a READ model, not another state machine: it does not own +execution, cannot steer agents, and can be rebuilt from the journal at any time. +Projection verifies the complete hash chain first, so an operator view can never +silently render a mutated or reordered observer history as trustworthy state. + +Topology comes only from Runtime's canonical `agent.spawn` facts. Terminal node +state comes only from `agent.child`; concrete run state comes only from the root +`agent.run` lifecycle emitted by `supervisePursuit`. Node identity is scoped to the +concrete Runtime run so independent trees may both contain `root:s0` without aliasing. + +#### Parameters + +##### records + +readonly [`ObserverRecord`](#observerrecord)[] + +#### Returns + +[`PursuitProjection`](#pursuitprojection) + +*** + +### supervisePursuit() + +> **supervisePursuit**(`profile`, `task`, `opts`): `Promise`\<[`SupervisedPursuitResult`](#supervisedpursuitresult)\<\{ `rootProviderModel`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `kind`: `"no-winner"`; `reason`: `"budget-exhausted"` \| `"all-children-down"` \| `"aborted"`; `tree`: [`TreeView`](runtime.md#treeview); `downCount`: `number`; `spentTotal`: [`Spend`](index.md#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `spendGaps?`: readonly [`SpendGap`](index.md#spendgap)[]; `error?`: `undefined`; \} \| \{ `rootProviderModel`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `kind`: `"no-winner"`; `reason`: `"driver-failed"`; `tree`: [`TreeView`](runtime.md#treeview); `downCount`: `number`; `spentTotal`: [`Spend`](index.md#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `spendGaps?`: readonly [`SpendGap`](index.md#spendgap)[]; `error`: [`NoWinnerError`](runtime.md#nowinnererror); \} \| \{ `rootProviderModel`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `kind`: `"winner"`; `out`: `unknown`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `tree`: [`TreeView`](runtime.md#treeview); `spentTotal`: [`Spend`](index.md#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `spendGaps?`: readonly [`SpendGap`](index.md#spendgap)[]; `spentBreakdown?`: \{ `driverInference`: [`Spend`](index.md#spend); `childWork`: [`Spend`](index.md#spend); \}; \}\>\> + +One-call durable pursuit execution over the canonical `supervise()` kernel. + +This is an adapter, not a second executor: it composes a durable third-person +observer into Runtime's existing recursive hook stream and then rebuilds the +operator projection after the same `supervise()` call settles. Agents never +receive the observer path or projection and their behavior does not depend on it. + +Every concrete execution writes only inside its own `runDir`. Cross-run pursuit +aggregation is therefore lock-free at the observer layer: reuse `pursuitId` across +run directories and let Intelligence join the independently verified projections. + +#### Parameters + +##### profile + +`AgentProfile` + +##### task + +`unknown` + +##### opts + +[`SupervisePursuitOptions`](#supervisepursuitoptions) + +#### Returns + +`Promise`\<[`SupervisedPursuitResult`](#supervisedpursuitresult)\<\{ `rootProviderModel`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `kind`: `"no-winner"`; `reason`: `"budget-exhausted"` \| `"all-children-down"` \| `"aborted"`; `tree`: [`TreeView`](runtime.md#treeview); `downCount`: `number`; `spentTotal`: [`Spend`](index.md#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `spendGaps?`: readonly [`SpendGap`](index.md#spendgap)[]; `error?`: `undefined`; \} \| \{ `rootProviderModel`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `kind`: `"no-winner"`; `reason`: `"driver-failed"`; `tree`: [`TreeView`](runtime.md#treeview); `downCount`: `number`; `spentTotal`: [`Spend`](index.md#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `spendGaps?`: readonly [`SpendGap`](index.md#spendgap)[]; `error`: [`NoWinnerError`](runtime.md#nowinnererror); \} \| \{ `rootProviderModel`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `kind`: `"winner"`; `out`: `unknown`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `tree`: [`TreeView`](runtime.md#treeview); `spentTotal`: [`Spend`](index.md#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](index.md#providermodelexecutionevidence); `spendGaps?`: readonly [`SpendGap`](index.md#spendgap)[]; `spentBreakdown?`: \{ `driverInference`: [`Spend`](index.md#spend); `childWork`: [`Spend`](index.md#spend); \}; \}\>\> *** diff --git a/docs/api/index.md b/docs/api/index.md index 0859f13c..387e7680 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -7782,6 +7782,12 @@ Idempotency-Key header (e.g. the runId) — safe retries + upsert. > **id**: `string` +##### pursuitId? + +> `optional` **pursuitId?**: `string` + +Stable identity for the long-lived objective. One pursuit may contain many runs. + ##### runId > **runId**: `string` @@ -7860,6 +7866,12 @@ Idempotency-Key header (e.g. the runId) — safe retries + upsert. > **id**: `string` +##### pursuitId? + +> `optional` **pursuitId?**: `string` + +Stable identity for the long-lived objective. One pursuit may contain many runs. + ##### runId > **runId**: `string` @@ -11861,6 +11873,11 @@ Runtime hook contracts. Hooks are execution-scoped observers, not part of an `AgentProfile`: profiles stay portable agent recipes; hooks attach to the loop or product harness that is running the profile. +A `pursuitId` is deliberately orthogonal to `runId`: a pursuit can span many +resumed/retried/forked runs while every event remains attributable to the +durable objective that caused it. The observer plane is outside the agent +environment and must never be required for agent correctness. + *** ### RuntimeHookTarget diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 21194206..09ee935f 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -370,22 +370,30 @@ Import from `@tangle-network/agent-runtime/conversation` — 54 exports. ### Product chat turns — edge-safe streaming, persistence, and stable execution IDs -Import from `@tangle-network/agent-runtime/durable` — 11 exports. +Import from `@tangle-network/agent-runtime/durable` — 28 exports. | Symbol | Kind | Summary | |---|---|---| +| `createFileObserverHooks` | function | Build the canonical durable observer hook in one call. | | `deriveExecutionId` | function | Derive a stable execution id from the run identity. | | `discoverDurableSupervisionRun` | function | Discover the stable identities recorded by Runtime's durable supervision | | `handleChatTurn` | function | Run one chat turn. Returns immediately with a `ReadableStream` body; | +| `observerRecordDigest` | function | Compute the canonical SHA-256 digest for an unsigned observer record. | +| `projectPursuit` | function | Fold one append-only execution journal into a deterministic operator projection. | +| `supervisePursuit` | function | One-call durable pursuit execution over the canonical `supervise()` kernel. | +| `verifyObserverRecords` | function | Verify identity, monotonic sequence, payload shape, and the complete digest chain. | +| `FileObserverJournal` | class | Durable, append-only third-person history for one concrete Runtime execution. | +| `SupervisePursuitError` | class | A failed Runtime execution whose complete third-person projection was retained. | | `ChatStreamEvent` | interface | The NDJSON line protocol every product chat client already speaks. | | `ChatTurnHooks` | interface | Product callbacks invoked while one chat turn runs. | | `ChatTurnIdentity` | interface | Identity of a chat turn. `tenantId` is the workspace id for workspace- | | `ChatTurnProducer` | interface | The live side of a turn returned by the product's `produce` hook. | | `ChatTurnResult` | interface | HTTP response values returned for one chat turn. | | `DurableSupervisionDiscovery` | interface | Identities discoverable from one `supervise({ runDir })` directory without | +| `ObserverRecord` | interface | One immutable record in the observer plane. `sequence` is journal order, not | | `RunChatTurnInput` | interface | Inputs for one streamed product chat turn. | -**Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `DurableCoordinationStreamIdentity`. +**Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `DurableCoordinationStreamIdentity`, `ObserverJournal`, `PursuitNodeProjection`, `PursuitProjection`, `PursuitRunProjection`, `SupervisedPursuitResult`, `SupervisePursuitOptions`, `ObserverRecordKind`, `PursuitNodeStatus`, `PursuitRunStatus`. ### Bounded tool calls for browser and edge runtimes diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 76f1ea10..0d9628d7 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -14905,6 +14905,7 @@ caller that owns the code registers it here once and names it from data thereaft #### Extended by +- [`SupervisePursuitOptions`](durable.md#supervisepursuitoptions) - [`SuperviseTestOptions`](testing.md#supervisetestoptions) #### Properties diff --git a/src/durable/index.ts b/src/durable/index.ts index b00f874c..3ff0f4d3 100644 --- a/src/durable/index.ts +++ b/src/durable/index.ts @@ -12,6 +12,12 @@ * persist and pass as both execution and turn identity on dispatch. * - `discoverDurableSupervisionRun`: inspect a durable supervision directory * without already knowing the root/run identities written inside it. + * - `FileObserverJournal`: tamper-evident, append-only third-person history + * for one concrete Runtime execution. + * - `projectPursuit`: a rebuildable operator read model over that history; + * it owns no execution or coordination semantics. + * - `supervisePursuit`: one-call adapter over canonical `supervise()` that + * gives each isolated run a stable cross-run pursuit identity. */ export type { @@ -24,6 +30,29 @@ export type { } from './chat-engine' export { handleChatTurn } from './chat-engine' export { deriveExecutionId } from './execution-handle' +export { + createFileObserverHooks, + FileObserverJournal, + type ObserverJournal, + type ObserverRecord, + type ObserverRecordKind, + observerRecordDigest, + verifyObserverRecords, +} from './observer-journal' +export { + type PursuitNodeProjection, + type PursuitNodeStatus, + type PursuitProjection, + type PursuitRunProjection, + type PursuitRunStatus, + projectPursuit, +} from './observer-projection' +export { + type SupervisedPursuitResult, + SupervisePursuitError, + type SupervisePursuitOptions, + supervisePursuit, +} from './supervise-pursuit' export { type DurableCoordinationStreamIdentity, type DurableSupervisionDiscovery, diff --git a/src/durable/observer-journal.ts b/src/durable/observer-journal.ts new file mode 100644 index 00000000..6f9d9924 --- /dev/null +++ b/src/durable/observer-journal.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + type RuntimeDecisionPoint, + type RuntimeHookEvent, + type RuntimeHooks, + withPursuitContext, +} from '../runtime-hooks' +import { parseCommittedJsonLines, prepareJsonlAppend, writeAllBytes } from './jsonl-file' + +export type ObserverRecordKind = 'event' | 'decision' + +/** + * One immutable record in the observer plane. `sequence` is journal order, not + * execution order; causal/runtime order remains available on the underlying event. + * `previousDigest` + `digest` make deletion, reordering, or mutation detectable. + */ +export interface ObserverRecord { + readonly schemaVersion: 1 + readonly pursuitId: string + readonly sequence: number + readonly kind: ObserverRecordKind + readonly observedAt: number + readonly previousDigest?: string + readonly event?: RuntimeHookEvent + readonly decision?: RuntimeDecisionPoint + readonly digest: string +} + +export interface ObserverJournal { + appendEvent(event: RuntimeHookEvent): Promise + appendDecision(point: RuntimeDecisionPoint): Promise + read(): Promise + hooks(): RuntimeHooks +} + +type UnsignedObserverRecord = Omit + +/** + * Durable, append-only third-person history for one concrete Runtime execution. + * It consumes Runtime's existing hook stream and does not participate in execution + * decisions. A broken observer therefore cannot change what an agent is allowed to do. + * + * The write discipline deliberately matches `FileSpawnJournal`: serialized appends, + * torn-tail recovery, short-write handling, and fsync before acknowledgement. One + * execution owns one journal file; higher-level pursuit aggregation joins isolated + * journals by `pursuitId` instead of making independent processes share a write head. + */ +export class FileObserverJournal implements ObserverJournal { + readonly path: string + readonly pursuitId: string + private tail: Promise = Promise.resolve() + private initialized = false + private sequence = 0 + private previousDigest: string | undefined + private appendFailure: Error | undefined + + constructor(path: string, pursuitId: string) { + const stableId = pursuitId.trim() + if (stableId.length === 0) { + throw new TypeError('FileObserverJournal: pursuitId must be non-empty') + } + this.path = resolve(path) + this.pursuitId = stableId + } + + hooks(): RuntimeHooks { + return withPursuitContext(this.pursuitId, { + onEvent: (event) => this.appendEvent(event).then(() => undefined), + onDecisionPoint: (point) => this.appendDecision(point).then(() => undefined), + }) + } + + appendEvent(event: RuntimeHookEvent): Promise { + return this.enqueue('event', event) + } + + appendDecision(point: RuntimeDecisionPoint): Promise { + return this.enqueue('decision', point) + } + + async read(): Promise { + await this.tail + this.assertComplete() + let text: string + try { + text = await readFile(this.path, 'utf8') + } catch (error) { + if (isNoEnt(error)) return [] + throw error + } + return verifyObserverRecords( + parseCommittedJsonLines(text, this.path), + this.pursuitId, + ) + } + + private enqueue( + kind: ObserverRecordKind, + value: RuntimeHookEvent | RuntimeDecisionPoint, + ): Promise { + if (value.pursuitId !== this.pursuitId) { + return Promise.reject( + new Error( + `FileObserverJournal: ${kind} pursuitId ${String(value.pursuitId)} does not match ${this.pursuitId}`, + ), + ) + } + + let result: ObserverRecord | undefined + const operation = this.tail.then(async () => { + this.assertComplete() + try { + await this.initialize() + } catch (error) { + this.appendFailure ??= toError(error) + throw error + } + + const unsigned: UnsignedObserverRecord = { + schemaVersion: 1, + pursuitId: this.pursuitId, + sequence: this.sequence + 1, + kind, + observedAt: Date.now(), + ...(this.previousDigest ? { previousDigest: this.previousDigest } : {}), + ...(kind === 'event' + ? { event: value as RuntimeHookEvent } + : { decision: value as RuntimeDecisionPoint }), + } + const record: ObserverRecord = Object.freeze({ + ...unsigned, + digest: observerRecordDigest(unsigned), + }) + try { + await this.writeRecord(record) + } catch (error) { + this.appendFailure ??= toError(error) + throw error + } + this.sequence = record.sequence + this.previousDigest = record.digest + result = record + }) + this.tail = operation.then( + () => undefined, + () => undefined, + ) + return operation.then(() => { + if (!result) throw new Error('FileObserverJournal: append completed without a record') + return result + }) + } + + private async initialize(): Promise { + if (this.initialized) return + const records = await this.readExistingUnsafe() + const verified = verifyObserverRecords(records, this.pursuitId) + const tail = verified.at(-1) + this.sequence = tail?.sequence ?? 0 + this.previousDigest = tail?.digest + // Only latch after recovery + verification succeed. A transient read error or + // corruption must never leave an instance pretending it initialized cleanly. + this.initialized = true + } + + private async readExistingUnsafe(): Promise { + let text: string + try { + text = await readFile(this.path, 'utf8') + } catch (error) { + if (isNoEnt(error)) return [] + throw error + } + return parseCommittedJsonLines(text, this.path) + } + + private async writeRecord(record: ObserverRecord): Promise { + const fs = await import('node:fs/promises') + const path = await import('node:path') + await fs.mkdir(path.dirname(this.path), { recursive: true }) + const needsSeparator = await prepareJsonlAppend(this.path) + const handle = await fs.open(this.path, 'a') + try { + await writeAllBytes(handle, `${needsSeparator ? '\n' : ''}${JSON.stringify(record)}\n`) + await handle.sync() + } finally { + await handle.close() + } + } + + private assertComplete(): void { + if (!this.appendFailure) return + throw new Error( + 'FileObserverJournal: a prior durable append failed; observer completeness is unknown', + { cause: this.appendFailure }, + ) + } +} + +/** Verify identity, monotonic sequence, payload shape, and the complete digest chain. */ +export function verifyObserverRecords( + records: readonly ObserverRecord[], + pursuitId?: string, +): readonly ObserverRecord[] { + let previousDigest: string | undefined + let expectedSequence = 1 + for (const record of records) { + if (record.schemaVersion !== 1) throw new Error('observer journal: unsupported schemaVersion') + if (pursuitId !== undefined && record.pursuitId !== pursuitId) { + throw new Error(`observer journal: pursuit identity mismatch at sequence ${record.sequence}`) + } + if (record.sequence !== expectedSequence) { + throw new Error( + `observer journal: non-contiguous sequence ${record.sequence}; expected ${expectedSequence}`, + ) + } + if (record.previousDigest !== previousDigest) { + throw new Error(`observer journal: digest-chain break at sequence ${record.sequence}`) + } + if ((record.kind === 'event') === (record.event === undefined)) { + throw new Error(`observer journal: invalid event payload at sequence ${record.sequence}`) + } + if ((record.kind === 'decision') === (record.decision === undefined)) { + throw new Error(`observer journal: invalid decision payload at sequence ${record.sequence}`) + } + if (record.event !== undefined && record.event.pursuitId !== record.pursuitId) { + throw new Error( + `observer journal: nested event pursuit mismatch at sequence ${record.sequence}`, + ) + } + if (record.decision !== undefined && record.decision.pursuitId !== record.pursuitId) { + throw new Error( + `observer journal: nested decision pursuit mismatch at sequence ${record.sequence}`, + ) + } + const { digest, ...unsigned } = record + const expected = observerRecordDigest(unsigned) + if (digest !== expected) { + throw new Error(`observer journal: digest mismatch at sequence ${record.sequence}`) + } + previousDigest = digest + expectedSequence += 1 + } + return Object.freeze([...records]) +} + +/** Compute the canonical SHA-256 digest for an unsigned observer record. */ +export function observerRecordDigest(record: Omit): string { + return createHash('sha256').update(JSON.stringify(record)).digest('hex') +} + +/** Build the canonical durable observer hook in one call. */ +export function createFileObserverHooks( + path: string, + pursuitId: string, +): { + readonly journal: FileObserverJournal + readonly hooks: RuntimeHooks +} { + const journal = new FileObserverJournal(path, pursuitId) + return { journal, hooks: journal.hooks() } +} + +function isNoEnt(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ) +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/src/durable/observer-projection.ts b/src/durable/observer-projection.ts new file mode 100644 index 00000000..376686de --- /dev/null +++ b/src/durable/observer-projection.ts @@ -0,0 +1,331 @@ +import type { RuntimeDecisionKind, RuntimeHookTarget } from '../runtime-hooks' +import { type ObserverRecord, verifyObserverRecords } from './observer-journal' + +export type PursuitRunStatus = 'running' | 'done' | 'failed' + +export interface PursuitRunProjection { + readonly runId: string + readonly status: PursuitRunStatus + readonly settledAt?: number + readonly error?: string + readonly firstSequence: number + readonly lastSequence: number + readonly firstObservedAt: number + readonly lastObservedAt: number + readonly eventCount: number + readonly decisionCount: number + readonly targets: Readonly> + readonly decisions: Readonly> +} + +export type PursuitNodeStatus = 'running' | 'done' | 'down' + +export interface PursuitNodeProjection { + readonly id: string + readonly parentId?: string + /** Node ids are scoped to this concrete Runtime tree; `(runId,id)` is identity. */ + readonly runId: string + readonly label?: string + readonly runtime?: string + readonly depth?: number + readonly assignmentId?: string + readonly identity?: unknown + readonly budget?: unknown + readonly status: PursuitNodeStatus + readonly settledAt?: number + readonly spent?: unknown + readonly outRef?: string + readonly score?: number + readonly valid?: boolean + readonly reason?: string + readonly infra?: boolean + readonly wait?: unknown + readonly firstSequence: number + readonly lastSequence: number + readonly firstObservedAt: number + readonly lastObservedAt: number + readonly eventCount: number +} + +export interface PursuitProjection { + readonly pursuitId: string + /** Number of records in this concrete execution journal. */ + readonly sequence: number + /** Digest-chain tip for this concrete execution journal. */ + readonly chainTip: string + readonly firstObservedAt: number + readonly lastObservedAt: number + readonly runs: readonly PursuitRunProjection[] + readonly nodes: readonly PursuitNodeProjection[] + readonly eventCount: number + readonly decisionCount: number +} + +type MutableRun = { + runId: string + status: PursuitRunStatus + settledAt?: number + error?: string + firstSequence: number + lastSequence: number + firstObservedAt: number + lastObservedAt: number + eventCount: number + decisionCount: number + targets: Record + decisions: Record +} + +type MutableNode = { + id: string + parentId?: string + runId: string + label?: string + runtime?: string + depth?: number + assignmentId?: string + identity?: unknown + budget?: unknown + status: PursuitNodeStatus + settledAt?: number + spent?: unknown + outRef?: string + score?: number + valid?: boolean + reason?: string + infra?: boolean + wait?: unknown + firstSequence: number + lastSequence: number + firstObservedAt: number + lastObservedAt: number + eventCount: number +} + +/** + * Fold one append-only execution journal into a deterministic operator projection. + * + * This is intentionally a READ model, not another state machine: it does not own + * execution, cannot steer agents, and can be rebuilt from the journal at any time. + * Projection verifies the complete hash chain first, so an operator view can never + * silently render a mutated or reordered observer history as trustworthy state. + * + * Topology comes only from Runtime's canonical `agent.spawn` facts. Terminal node + * state comes only from `agent.child`; concrete run state comes only from the root + * `agent.run` lifecycle emitted by `supervisePursuit`. Node identity is scoped to the + * concrete Runtime run so independent trees may both contain `root:s0` without aliasing. + */ +export function projectPursuit(records: readonly ObserverRecord[]): PursuitProjection { + if (records.length === 0) { + throw new TypeError('projectPursuit: at least one observer record is required') + } + const pursuitId = records[0]!.pursuitId + const verified = verifyObserverRecords(records, pursuitId) + const runs = new Map() + const nodes = new Map() + let eventCount = 0 + let decisionCount = 0 + + for (const record of verified) { + const observed = record.event ?? record.decision + if (!observed) throw new Error(`projectPursuit: record ${record.sequence} has no observation`) + const run = getRun(runs, observed.runId, record) + run.lastSequence = record.sequence + run.lastObservedAt = record.observedAt + + if (record.event) { + eventCount += 1 + run.eventCount += 1 + increment(run.targets, record.event.target) + projectRunActivity(run, record) + projectSpawnNode(nodes, record) + projectNodeActivity(nodes, record) + } else if (record.decision) { + decisionCount += 1 + run.decisionCount += 1 + increment(run.decisions, record.decision.kind) + } + } + + const first = verified[0]! + const last = verified.at(-1)! + return Object.freeze({ + pursuitId, + sequence: last.sequence, + chainTip: last.digest, + firstObservedAt: first.observedAt, + lastObservedAt: last.observedAt, + runs: Object.freeze( + [...runs.values()] + .sort((a, b) => a.firstSequence - b.firstSequence || a.runId.localeCompare(b.runId)) + .map(freezeRun), + ), + nodes: Object.freeze( + [...nodes.values()] + .sort( + (a, b) => + a.firstSequence - b.firstSequence || + a.runId.localeCompare(b.runId) || + a.id.localeCompare(b.id), + ) + .map(freezeNode), + ), + eventCount, + decisionCount, + }) +} + +function getRun(runs: Map, runId: string, record: ObserverRecord): MutableRun { + const existing = runs.get(runId) + if (existing) return existing + const created: MutableRun = { + runId, + status: 'running', + firstSequence: record.sequence, + lastSequence: record.sequence, + firstObservedAt: record.observedAt, + lastObservedAt: record.observedAt, + eventCount: 0, + decisionCount: 0, + targets: {}, + decisions: {}, + } + runs.set(runId, created) + return created +} + +function projectRunActivity(run: MutableRun, record: ObserverRecord): void { + const event = record.event + if (event?.target !== 'agent.run') return + const payload = objectRecord(event.payload) + const status = stringField(payload, 'status') + if (event.phase === 'after' || status === 'done') { + run.status = 'done' + run.settledAt = record.observedAt + return + } + if (event.phase !== 'error' && status !== 'failed') return + run.status = 'failed' + run.settledAt = record.observedAt + const error = stringField(payload, 'error') + if (error) run.error = error +} + +function nodeKey(runId: string, nodeId: string): string { + return `${runId}\u0000${nodeId}` +} + +function projectSpawnNode(nodes: Map, record: ObserverRecord): void { + const event = record.event + if (event?.target !== 'agent.spawn') return + const payload = objectRecord(event.payload) + const childId = stringField(payload, 'childId') + if (!childId) return + const key = nodeKey(event.runId, childId) + const existing = nodes.get(key) + if (existing) { + existing.lastSequence = record.sequence + existing.lastObservedAt = record.observedAt + existing.eventCount += 1 + return + } + const label = stringField(payload, 'label') + const runtime = stringField(payload, 'runtime') + const depth = numberField(payload, 'depth') + const assignmentId = stringField(payload, 'assignmentId') + nodes.set(key, { + id: childId, + ...(event.parentId ? { parentId: event.parentId } : {}), + runId: event.runId, + ...(label ? { label } : {}), + ...(runtime ? { runtime } : {}), + ...(depth !== undefined ? { depth } : {}), + ...(assignmentId ? { assignmentId } : {}), + ...(payload && Object.hasOwn(payload, 'identity') ? { identity: payload.identity } : {}), + ...(payload && Object.hasOwn(payload, 'budget') ? { budget: payload.budget } : {}), + status: 'running', + firstSequence: record.sequence, + lastSequence: record.sequence, + firstObservedAt: record.observedAt, + lastObservedAt: record.observedAt, + eventCount: 1, + }) +} + +function projectNodeActivity(nodes: Map, record: ObserverRecord): void { + const event = record.event + if (event === undefined) return + if (event.target === 'agent.spawn') return + const payload = objectRecord(event.payload) + const nodeId = + stringField(payload, 'childId') ?? + stringField(payload, 'nodeId') ?? + stringField(payload, 'workerId') + if (!nodeId) return + const node = nodes.get(nodeKey(event.runId, nodeId)) + if (!node) return + node.lastSequence = record.sequence + node.lastObservedAt = record.observedAt + node.eventCount += 1 + + if (event.target !== 'agent.child') return + const status = stringField(payload, 'status') + if (status !== 'done' && status !== 'down') return + node.status = status + node.settledAt = record.observedAt + if (payload && Object.hasOwn(payload, 'spent')) node.spent = payload.spent + const outRef = stringField(payload, 'outRef') + if (outRef) node.outRef = outRef + const score = numberField(payload, 'score') + if (score !== undefined) node.score = score + const valid = booleanField(payload, 'valid') + if (valid !== undefined) node.valid = valid + const reason = stringField(payload, 'reason') + if (reason) node.reason = reason + const infra = booleanField(payload, 'infra') + if (infra !== undefined) node.infra = infra + if (payload && Object.hasOwn(payload, 'wait')) node.wait = payload.wait +} + +function freezeRun(run: MutableRun): PursuitRunProjection { + return Object.freeze({ + ...run, + targets: Object.freeze({ ...run.targets }), + decisions: Object.freeze({ ...run.decisions }), + }) +} + +function freezeNode(node: MutableNode): PursuitNodeProjection { + return Object.freeze({ ...node }) +} + +function increment( + target: Record, + key: RuntimeHookTarget | RuntimeDecisionKind, +): void { + target[key] = (target[key] ?? 0) + 1 +} + +function objectRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function stringField(value: Record | undefined, key: string): string | undefined { + const field = value?.[key] + return typeof field === 'string' && field.length > 0 ? field : undefined +} + +function numberField(value: Record | undefined, key: string): number | undefined { + const field = value?.[key] + return typeof field === 'number' && Number.isFinite(field) ? field : undefined +} + +function booleanField( + value: Record | undefined, + key: string, +): boolean | undefined { + const field = value?.[key] + return typeof field === 'boolean' ? field : undefined +} diff --git a/src/durable/supervise-pursuit.ts b/src/durable/supervise-pursuit.ts new file mode 100644 index 00000000..24876732 --- /dev/null +++ b/src/durable/supervise-pursuit.ts @@ -0,0 +1,134 @@ +import { resolve } from 'node:path' +import { type SuperviseOptions, supervise } from '../runtime/supervise/supervise' +import type { SupervisorProfile } from '../runtime/supervise/supervisor-agent' +import { composeRuntimeHooks, type RuntimeHookEvent, withPursuitContext } from '../runtime-hooks' +import { createFileObserverHooks } from './observer-journal' +import { type PursuitProjection, projectPursuit } from './observer-projection' + +export interface SupervisePursuitOptions extends SuperviseOptions { + /** Stable objective identity spanning concrete Runtime runs. */ + readonly pursuitId: string + /** + * One concrete Runtime execution owns one durable directory and observer journal. + * A pursuit spanning several runs reuses `pursuitId` across distinct `runDir`s; + * Intelligence joins those isolated projections without a shared write head. + */ + readonly runDir: string +} + +export interface SupervisedPursuitResult { + readonly result: Result + readonly pursuit: PursuitProjection + readonly observerPath: string +} + +/** A failed Runtime execution whose complete third-person projection was retained. */ +export class SupervisePursuitError extends Error { + readonly pursuit: PursuitProjection + readonly observerPath: string + + constructor(cause: unknown, pursuit: PursuitProjection, observerPath: string) { + super(`supervisePursuit: ${errorMessage(cause)}`, { cause }) + this.name = 'SupervisePursuitError' + this.pursuit = pursuit + this.observerPath = observerPath + } +} + +/** + * One-call durable pursuit execution over the canonical `supervise()` kernel. + * + * This is an adapter, not a second executor: it composes a durable third-person + * observer into Runtime's existing recursive hook stream and then rebuilds the + * operator projection after the same `supervise()` call settles. Agents never + * receive the observer path or projection and their behavior does not depend on it. + * + * Every concrete execution writes only inside its own `runDir`. Cross-run pursuit + * aggregation is therefore lock-free at the observer layer: reuse `pursuitId` across + * run directories and let Intelligence join the independently verified projections. + */ +export async function supervisePursuit( + profile: SupervisorProfile, + task: unknown, + opts: SupervisePursuitOptions, +): Promise>>> { + const pursuitId = opts.pursuitId.trim() + if (pursuitId.length === 0) { + throw new TypeError('supervisePursuit: pursuitId must be non-empty') + } + const runDir = opts.runDir.trim() + if (runDir.length === 0) { + throw new TypeError('supervisePursuit: runDir must be non-empty') + } + + const observerPath = resolve(runDir, 'observer.jsonl') + const { pursuitId: _pursuitId, hooks, ...superviseOptions } = opts + const observer = createFileObserverHooks(observerPath, pursuitId) + const runId = superviseOptions.runId ?? 'supervise' + const now = superviseOptions.now ?? Date.now + + // Root lifecycle is an observer-plane fact, not something the manager has to + // narrate about itself. This also makes a zero-spawn/single-agent run observable. + await observer.journal.appendEvent(rootEvent(pursuitId, runId, 'before', now())) + + try { + const result = await supervise(profile, task, { + ...superviseOptions, + // The observer runs first so a caller hook that throws cannot prevent the + // canonical lifecycle fact from entering the durable journal. + hooks: withPursuitContext(pursuitId, composeRuntimeHooks(observer.hooks, hooks)), + }) + await observer.journal.appendEvent( + rootEvent(pursuitId, runId, 'after', now(), { status: 'done' }), + ) + return Object.freeze({ + result, + pursuit: projectPursuit(await observer.journal.read()), + observerPath, + }) + } catch (error) { + let pursuit: PursuitProjection | undefined + let observerError: unknown + try { + await observer.journal.appendEvent( + rootEvent(pursuitId, runId, 'error', now(), { + status: 'failed', + error: errorMessage(error), + }), + ) + pursuit = projectPursuit(await observer.journal.read()) + } catch (failure) { + observerError = failure + } + if (observerError !== undefined || pursuit === undefined) { + const causes = observerError === undefined ? [error] : [error, observerError] + throw new Error( + 'supervisePursuit: Runtime failed and durable observer completeness could not be proven', + { cause: new AggregateError(causes) }, + ) + } + throw new SupervisePursuitError(error, pursuit, observerPath) + } +} + +function rootEvent( + pursuitId: string, + runId: string, + phase: 'before' | 'after' | 'error', + timestamp: number, + payload?: Record, +): RuntimeHookEvent { + return Object.freeze({ + id: `${runId}:pursuit:${phase}:${timestamp}`, + pursuitId, + runId, + target: 'agent.run', + phase, + timestamp, + ...(payload ? { payload: Object.freeze({ ...payload }) } : {}), + }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/durable/tests/observer-journal.test.ts b/src/durable/tests/observer-journal.test.ts new file mode 100644 index 00000000..ba75f076 --- /dev/null +++ b/src/durable/tests/observer-journal.test.ts @@ -0,0 +1,94 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { FileObserverJournal } from '../observer-journal' + +function event(id: string, pursuitId = 'pursuit:test') { + return { + id, + pursuitId, + runId: 'run:1', + target: 'agent.spawn' as const, + phase: 'event' as const, + timestamp: 1, + payload: { child: id }, + } +} + +describe('FileObserverJournal', () => { + it('persists one execution journal with a verified chain', async () => { + const dir = await mkdtemp(join(tmpdir(), 'runtime-observer-')) + const path = join(dir, 'observer.jsonl') + const journal = new FileObserverJournal(path, 'pursuit:test') + + await journal.appendEvent(event('e1')) + await journal.appendEvent({ ...event('e2'), runId: 'run:2' }) + + const records = await journal.read() + expect(records.map((record) => record.sequence)).toEqual([1, 2]) + expect(records.map((record) => record.pursuitId)).toEqual(['pursuit:test', 'pursuit:test']) + expect(records[1]?.previousDigest).toBe(records[0]?.digest) + expect(records[1]?.event?.runId).toBe('run:2') + }) + + it('stamps every hook event and decision with the pursuit identity', async () => { + const dir = await mkdtemp(join(tmpdir(), 'runtime-observer-hooks-')) + const journal = new FileObserverJournal(join(dir, 'observer.jsonl'), 'pursuit:hooks') + const hooks = journal.hooks() + + await hooks.onEvent?.( + { + id: 'event', + runId: 'run:1', + target: 'agent.child', + phase: 'event', + timestamp: 1, + }, + {}, + ) + await hooks.onDecisionPoint?.( + { + id: 'decision', + runId: 'run:1', + stepIndex: 0, + kind: 'continue', + candidateActions: ['continue'], + evidence: [], + }, + {}, + ) + + const records = await journal.read() + expect(records).toHaveLength(2) + expect(records[0]?.event?.pursuitId).toBe('pursuit:hooks') + expect(records[1]?.decision?.pursuitId).toBe('pursuit:hooks') + }) + + it('fails closed on identity conflicts and detects mutation', async () => { + const dir = await mkdtemp(join(tmpdir(), 'runtime-observer-corrupt-')) + const path = join(dir, 'observer.jsonl') + const journal = new FileObserverJournal(path, 'pursuit:one') + + await expect(journal.appendEvent(event('wrong', 'pursuit:two'))).rejects.toThrow( + /does not match/, + ) + await journal.appendEvent(event('right', 'pursuit:one')) + + const text = await readFile(path, 'utf8') + await writeFile(path, text.replace('agent.spawn', 'agent.child'), 'utf8') + await expect(new FileObserverJournal(path, 'pursuit:one').read()).rejects.toThrow( + /digest mismatch/, + ) + }) + + it('never returns a trusted projection after a durable append failure', async () => { + const dir = await mkdtemp(join(tmpdir(), 'runtime-observer-write-failure-')) + const blocker = join(dir, 'not-a-directory') + await writeFile(blocker, 'block', 'utf8') + const journal = new FileObserverJournal(join(blocker, 'observer.jsonl'), 'pursuit:one') + + await expect(journal.appendEvent(event('cannot-write', 'pursuit:one'))).rejects.toThrow() + await expect(journal.read()).rejects.toThrow(/completeness is unknown/) + }) +}) diff --git a/src/durable/tests/observer-projection.test.ts b/src/durable/tests/observer-projection.test.ts new file mode 100644 index 00000000..135dd50d --- /dev/null +++ b/src/durable/tests/observer-projection.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest' +import { type ObserverRecord, observerRecordDigest } from '../observer-journal' +import { projectPursuit } from '../observer-projection' + +function record( + sequence: number, + input: Omit, + previousDigest?: string, +): ObserverRecord { + const unsigned = { + schemaVersion: 1 as const, + pursuitId: 'pursuit:test', + sequence, + observedAt: sequence * 10, + ...(previousDigest ? { previousDigest } : {}), + ...input, + } + return { ...unsigned, digest: observerRecordDigest(unsigned) } +} + +describe('projectPursuit', () => { + it('keeps run lifecycle and recursive terminal truth isolated per Runtime run', () => { + const first = record(1, { + kind: 'event', + event: { + id: 'run-a-before', + pursuitId: 'pursuit:test', + runId: 'run:1', + target: 'agent.run', + phase: 'before', + timestamp: 1, + }, + }) + const second = record( + 2, + { + kind: 'event', + event: { + id: 'spawn-a', + pursuitId: 'pursuit:test', + runId: 'run:1', + target: 'agent.spawn', + phase: 'after', + timestamp: 2, + parentId: 'root', + payload: { + childId: 'root:s0', + label: 'researcher', + runtime: 'sandbox', + depth: 0, + identity: { candidateDigest: 'sha256:a' }, + }, + }, + }, + first.digest, + ) + const third = record( + 3, + { + kind: 'decision', + decision: { + id: 'decision-a', + pursuitId: 'pursuit:test', + runId: 'run:1', + stepIndex: 0, + kind: 'continue', + candidateActions: ['continue'], + evidence: [], + }, + }, + second.digest, + ) + const fourth = record( + 4, + { + kind: 'event', + event: { + id: 'run-b-before', + pursuitId: 'pursuit:test', + runId: 'run:2', + target: 'agent.run', + phase: 'before', + timestamp: 4, + }, + }, + third.digest, + ) + // A different top-level Runtime run may legitimately mint the same local node id. + const fifth = record( + 5, + { + kind: 'event', + event: { + id: 'spawn-b', + pursuitId: 'pursuit:test', + runId: 'run:2', + target: 'agent.spawn', + phase: 'after', + timestamp: 5, + parentId: 'root', + payload: { + childId: 'root:s0', + label: 'critic', + runtime: 'bridge', + depth: 0, + }, + }, + }, + fourth.digest, + ) + const sixth = record( + 6, + { + kind: 'event', + event: { + id: 'settle-b', + pursuitId: 'pursuit:test', + runId: 'run:2', + target: 'agent.child', + phase: 'after', + timestamp: 6, + parentId: 'root', + payload: { + childId: 'root:s0', + status: 'done', + outRef: 'sha256:out', + score: 0.9, + valid: true, + spent: { tokens: 123 }, + }, + }, + }, + fifth.digest, + ) + const seventh = record( + 7, + { + kind: 'event', + event: { + id: 'run-b-after', + pursuitId: 'pursuit:test', + runId: 'run:2', + target: 'agent.run', + phase: 'after', + timestamp: 7, + payload: { status: 'done' }, + }, + }, + sixth.digest, + ) + + const view = projectPursuit([first, second, third, fourth, fifth, sixth, seventh]) + expect(view.pursuitId).toBe('pursuit:test') + expect(view.sequence).toBe(7) + expect(view.chainTip).toBe(seventh.digest) + expect(view.runs.map((run) => [run.runId, run.status])).toEqual([ + ['run:1', 'running'], + ['run:2', 'done'], + ]) + expect(view.runs[0]?.decisions.continue).toBe(1) + expect(view.runs[1]?.settledAt).toBe(70) + expect(view.nodes.map((node) => [node.runId, node.id, node.parentId])).toEqual([ + ['run:1', 'root:s0', 'root'], + ['run:2', 'root:s0', 'root'], + ]) + expect(view.nodes[0]?.status).toBe('running') + expect(view.nodes[1]).toMatchObject({ + status: 'done', + settledAt: 60, + outRef: 'sha256:out', + score: 0.9, + valid: true, + spent: { tokens: 123 }, + }) + }) + + it('projects an authoritative root failure without treating a child as the pursuit verdict', () => { + const first = record(1, { + kind: 'event', + event: { + id: 'before', + pursuitId: 'pursuit:test', + runId: 'run:failed', + target: 'agent.run', + phase: 'before', + timestamp: 1, + }, + }) + const second = record( + 2, + { + kind: 'event', + event: { + id: 'error', + pursuitId: 'pursuit:test', + runId: 'run:failed', + target: 'agent.run', + phase: 'error', + timestamp: 2, + payload: { status: 'failed', error: 'driver crashed' }, + }, + }, + first.digest, + ) + + expect(projectPursuit([first, second]).runs[0]).toMatchObject({ + runId: 'run:failed', + status: 'failed', + settledAt: 20, + error: 'driver crashed', + }) + }) + + it('refuses mixed or tampered observer history before projecting it', () => { + const first = record(1, { + kind: 'event', + event: { + id: 'a', + pursuitId: 'pursuit:test', + runId: 'run:1', + target: 'agent.run', + phase: 'event', + timestamp: 1, + }, + }) + const secondUnsigned = { + schemaVersion: 1 as const, + pursuitId: 'pursuit:other', + sequence: 2, + observedAt: 20, + previousDigest: first.digest, + kind: 'event' as const, + event: { + id: 'b', + pursuitId: 'pursuit:other', + runId: 'run:2', + target: 'agent.run' as const, + phase: 'event' as const, + timestamp: 2, + }, + } + const second: ObserverRecord = { + ...secondUnsigned, + digest: observerRecordDigest(secondUnsigned), + } + expect(() => projectPursuit([first, second])).toThrow(/pursuit/i) + + const tampered: ObserverRecord = { + ...first, + event: { ...first.event!, runId: 'run:forged' }, + } + expect(() => projectPursuit([tampered])).toThrow(/digest mismatch/) + }) +}) diff --git a/src/runtime-hooks.ts b/src/runtime-hooks.ts index 6cb9d5ba..5ed8c0fe 100644 --- a/src/runtime-hooks.ts +++ b/src/runtime-hooks.ts @@ -4,6 +4,11 @@ * `AgentProfile`: profiles stay portable agent recipes; hooks attach to the * loop or product harness that is running the profile. * + * A `pursuitId` is deliberately orthogonal to `runId`: a pursuit can span many + * resumed/retried/forked runs while every event remains attributable to the + * durable objective that caused it. The observer plane is outside the agent + * environment and must never be required for agent correctness. + * * @experimental */ @@ -35,6 +40,8 @@ export type RuntimeDecisionKind = export interface RuntimeHookEvent { id: string + /** Stable identity for the long-lived objective. One pursuit may contain many runs. */ + pursuitId?: string runId: string scenarioId?: string target: RuntimeHookTarget @@ -59,6 +66,8 @@ export interface RuntimeDecisionEvidenceRef { export interface RuntimeDecisionPoint { id: string + /** Stable identity for the long-lived objective. One pursuit may contain many runs. */ + pursuitId?: string runId: string scenarioId?: string stepIndex: number @@ -108,6 +117,40 @@ export function defineRuntimeHooks(hooks: RuntimeHooks): RuntimeHooks { return hooks } +/** + * Attach a stable pursuit identity to the entire observer stream without changing + * agent code or teaching individual runtimes about pursuits. Because recursive Scope + * execution already inherits one RuntimeHooks instance, this wrapper automatically + * covers descendants, nested drivers, and resumed execution that reuses the wrapper. + * + * Existing matching pursuit ids are preserved. A conflicting id fails closed: silently + * rewriting attribution would make the meta-observer untrustworthy. + */ +export function withPursuitContext(pursuitId: string, hooks: RuntimeHooks): RuntimeHooks { + const stableId = pursuitId.trim() + if (stableId.length === 0) throw new TypeError('withPursuitContext: pursuitId must be non-empty') + + const assertAndStamp = (value: T): T => { + if (value.pursuitId !== undefined && value.pursuitId !== stableId) { + throw new Error( + `withPursuitContext: observer identity conflict (${value.pursuitId} !== ${stableId})`, + ) + } + if (value.pursuitId === stableId) return value + return { ...value, pursuitId: stableId } + } + + return { + onEvent: hooks.onEvent + ? (event, context) => hooks.onEvent?.(assertAndStamp(event), context) + : undefined, + onDecisionPoint: hooks.onDecisionPoint + ? (point, context) => hooks.onDecisionPoint?.(assertAndStamp(point), context) + : undefined, + onHookError: hooks.onHookError, + } +} + /** * Merge several {@link RuntimeHooks} into one. Falsy entries are dropped (so you can * pass `flag && hooks`), and every observer's `onEvent`/`onDecisionPoint` fires for each