diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index a35249ed6..20b051b67 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -51,7 +51,6 @@ - [4.6 Abort and cancellation reconciliation](#46-abort-and-cancellation-reconciliation) - [4.7 Close — a controlled crash](#47-close--a-controlled-crash) - [4.8 Faults](#48-faults) - - [4.9 External finalization](#49-external-finalization) - [Part 5 — Public surface](#part-5--public-surface) - [5.1 The lane surface](#51-the-lane-surface) - [5.2 The harness](#52-the-harness) @@ -1278,220 +1277,106 @@ interface OperationMeta { ## 3.2 Operation state — the durable restart point -`operationState(operationId)` holds one total `OperationState` directly. Every durable transition replaces the whole value; the terminal transaction deletes it (§3.13). There is no finished member of the union — an ended operation has no state at all, and its outcome lives in `pi.lane.lastResult`. +`operationState(operationId)` holds one total `OperationState` directly. Every durable transition replaces the whole value; the terminal transaction deletes it (§3.13). There is no finished member of the union. -That value is authoritative after process loss, but it is not the finer instruction pointer of a live async procedure. For example, a fresh assistant procedure commits `effect_pending` and retains its JavaScript continuation through the request and settlement. If that continuation is lost, the same durable state becomes the restart point for unknown-outcome recovery (§4.1, §4.5). +The state has one namespaced `at` discriminator. There is no nested operation-kind, phase, generation-status, deferred-status, or structural-status dispatch hierarchy: ```ts -type OperationState = RunState | CompactionState | NavigationState; +type OperationState = + | RunStartingOperation // run.starting + | RunCheckpointOperation // run.checkpoint + | RunAssistantReadyOperation // run.assistant.ready + | RunAssistantEffectPendingOperation // run.assistant.effect_pending + | RunAssistantRetryWaitOperation // run.assistant.retry_wait + | RunToolsOperation // run.tools + | RunDeferredSuspendedOperation // run.deferred.suspended + | RunDeferredEffectPendingOperation // run.deferred.effect_pending + | RunCompactionDecidingOperation // run.compaction.deciding + | RunCompactionReadyOperation // run.compaction.ready + | RunCompactionEffectPendingOperation // run.compaction.effect_pending + | RunCompactionRetryWaitOperation // run.compaction.retry_wait + | RunFailureDrainOperation // run.failure_drain + | CompactionDecidingOperation // compaction.deciding + | CompactionReadyOperation // compaction.ready + | CompactionEffectPendingOperation // compaction.effect_pending + | CompactionRetryWaitOperation // compaction.retry_wait + | NavigationReadyToCommitOperation // navigation.ready_to_commit + | NavigationSummaryDecidingOperation // navigation.summary.deciding + | NavigationSummaryReadyOperation // navigation.summary.ready + | NavigationSummaryEffectPendingOperation // navigation.summary.effect_pending + | NavigationSummaryRetryWaitOperation; // navigation.summary.retry_wait +``` -/** Process-local owned projection assembled from two durable values. */ -interface Operation { - meta: OperationMeta; - state: OperationState; -} +Every leaf carries orthogonal `control`. Every `run.*` leaf also carries captured run settings, inbox ids, and `latestAssistantEntryId` through `RunScope`. Structural and navigation families similarly factor their shared durable data with intersections. `ToolBatch`/`ToolCall` remain a nested child collection state machine because parallel tool children genuinely update sibling statuses concurrently. +```ts type Control = | { status: "running" } | { status: "cancel_requested"; requestedAt: number; - /** Drained queue ids. Their `pendingEntry(id)` values survive the drain - and are deleted only by the terminal transaction (§3.11, §3.13). */ drainedSteer: string[]; drainedFollowUp: string[] }; -interface RunState { - kind: "run"; +interface RunScope { control: Control; - /** Captured atomically at acceptance; setters affect later operations. */ settings: { compaction: CompactionSettings; steeringMode: QueueMode; followUpMode: QueueMode; toolExecution: "sequential" | "parallel"; }; - phase: RunPhase; - inbox: Inbox; - /** Newest durable assistant generation/fetch response in this operation. */ + inbox: { steer: string[]; followUp: string[]; writes: string[] }; latestAssistantEntryId: string | null; } -interface CheckpointPhase { - kind: "checkpoint"; - continuation: Continuation; - /** Durable correlation source for the next generation step. */ +interface RunCheckpointOperation extends RunScope { + at: "run.checkpoint"; + continuation: + | { kind: "need_assistant"; overflowRecoveryUsed: boolean } + | { kind: "may_finish"; includeFinalAssistant: boolean }; triggerEntryId: string; - /** Threshold compaction is attempted at most once per trigger boundary. */ thresholdCheckedTriggerEntryId?: string; - /** Generate before draining another queued input after one-at-a-time drain. */ skipInboxOnce?: boolean; } -type RunPhase = - | { kind: "starting" } - | CheckpointPhase - | { kind: "assistant"; generation: Generation } - | { kind: "tools"; batch: ToolBatch } - | { kind: "compaction"; reason: "threshold" | "overflow"; - structural: StructuralDecision; resumeAfter: CheckpointPhase } - | { kind: "deferred"; deferred: Deferred } - | { kind: "failure_drain"; error: OperationError; provenance: - | { kind: "response"; entryId: string } - | { kind: "structural"; taskId: string } - | { kind: "configuration" } }; - -type Continuation = - | { kind: "need_assistant"; overflowRecoveryUsed: boolean } - | { kind: "may_finish"; includeFinalAssistant: boolean }; - -interface Inbox { - /** Reserved entry ids. Payloads — and, for writes, the entry type and - customType — live at `pendingEntry(id)` (§1.3, §2.2). */ - steer: string[]; - followUp: string[]; - writes: string[]; +interface RunAssistantReadyOperation extends RunScope { + at: "run.assistant.ready"; + generationContext: GenerationContext; + nextAttempt: number; +} +interface RunAssistantEffectPendingOperation extends RunScope { + at: "run.assistant.effect_pending"; + generationContext: GenerationContext; + attempt: number; + responseEntryId: string; + usageId: string; + intendedOutputLimit: number; + contextWindow: number; +} +interface RunAssistantRetryWaitOperation extends RunScope { + at: "run.assistant.retry_wait"; + generationContext: GenerationContext; + nextAttempt: number; + notBefore: number; + errorMessage: string; } -interface OperationError { code: string; message: string; details?: JsonValue } -``` - -Execution configuration failures use stable operation-error codes rather than identity suspension: `model_unavailable` carries `{ provider, modelId }`, and `configured_tools_unavailable` carries `{ tools: string[] }`. These errors are non-retryable because retrying the same absent implementation cannot change the process registry. They are distinct from a missing requested tool, which becomes an ordinary `isError` tool-result message (§3.8). - -A queue item is one entry id; everything else about it — payload, write type, `customType` — is dereferenced from its `pendingEntry(id)` value. - -`latestAssistantEntryId` updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active. - -Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with `need_assistant(false)` and the appended entry as `triggerEntryId`. A `may_finish` checkpoint sets `triggerEntryId` to the entry that caused the boundary: the settled response for a `stop`/genuine-`length` settlement (§3.7), the newest result entry for an all-terminating tool batch (§3.8) — so threshold dedup (§3.12) and every later consumer always name an existing entry. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to `resumeAfter` with `thresholdCheckedTriggerEntryId = triggerEntryId`; decline, empty preparation, success, and crash therefore cannot recheck the same boundary. - -### Generation - -```ts -interface NormalizedRetryPolicy { maxAttempts: number; baseDelayMs: number } - -interface GenerationContext { - stepId: string; - triggerEntryId: string; - /** Inline snapshot of the lane configuration at step start. */ - configuration: LaneConfiguration; - streamOptions: AgentHarnessStreamOptions; - retryPolicy: NormalizedRetryPolicy; - /** Copied from the producing checkpoint's need_assistant continuation so a - settlement classified after crash-restore still knows whether overflow - recovery was already spent (§3.7, §3.9). */ - overflowRecoveryUsed: boolean; +interface RunToolsOperation extends RunScope { + at: "run.tools"; + batch: ToolBatch; } -type Generation = - | { status: "ready"; context: GenerationContext; nextAttempt: number } - | { status: "effect_pending"; context: GenerationContext; attempt: number; - responseEntryId: string; usageId: string; - intendedOutputLimit: number; contextWindow: number } - | { status: "retry_wait"; context: GenerationContext; nextAttempt: number; - notBefore: number; errorMessage: string }; -``` - -The context snapshots configuration, stream options, and retry policy **inline**; `LaneConfiguration` is small. Recovery therefore retains the exact provider/model and active-tool identities selected for the step even when their current implementations are unavailable. No frame count, cursor, or list identity is stored in the state: the procedure constructs `pendingAssistantFrames(operationId, responseEntryId)` from the effect-pending state (§3.7). For each attempt, `before_request` runs from generation `ready` (an elapsed retry wait first returns to `ready`). Its curated patch is composed with the context's captured base stream options, then `intendedOutputLimit` and `contextWindow` are calculated and persisted in the `effect_pending` intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned `before_payload`/`after_response` callbacks are mounted only after intent and cannot be replaced through stream options. - -### Tool batch - -```ts -interface ToolBatch { - assistantEntryId: string; - /** Producing generation/fetch snapshot; active tool names come from here. */ - configuration: LaneConfiguration; - /** The assistant generation step id; recovered tool events use it as turnId. */ - turnId: string; - calls: ToolCall[]; +interface RunDeferredSuspendedOperation extends RunScope, DeferredScope { + at: "run.deferred.suspended"; } - -type ToolCall = - | { status: "planned"; sourceIndex: number; resultEntryId: string } - | { status: "effect_pending"; sourceIndex: number; resultEntryId: string; - replay: "never" | "safe" } - | { status: "outcome_ready"; sourceIndex: number; resultEntryId: string; - terminate: boolean } - | { status: "completed"; sourceIndex: number; resultEntryId: string; - terminate: boolean }; -``` - -The source call comes from `assistantEntryId` plus `sourceIndex`. `sourceIndex` is the zero-based index in the assistant message's complete `content` array, not an ordinal in a filtered tool-call list; the indexed block must be a tool call. Large effective arguments live once in the `operationToolArgs(operationId, stepId, sourceIndex)` value — the producing generation's `stepId` disambiguates batches across turns — written at clearance (§3.8) and located by that deterministic key. Persist them unconditionally because `prepareArguments`, not only `before_tool`, may change them. - -`outcome_ready` means execution and post-effect hooks are finished and the complete finalized result is durable in `pendingEntry(resultEntryId)`, but an earlier source call prevents tree placement. Such a call never executes again. Completed calls form a source-ordered prefix. In parallel mode, the suffix may mix `planned`, `effect_pending`, and `outcome_ready`; source-ordered materialization alone extends the completed prefix. In sequential mode, the suffix contains at most one `effect_pending` or `outcome_ready` call before planned calls. - -### Deferred - -```ts -type Deferred = - | { status: "suspended"; stepId: string; sourceEntryId: string; poll: number; - configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions } - | { status: "effect_pending"; stepId: string; sourceEntryId: string; poll: number; - responseEntryId: string; usageId: string; - configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions }; -``` - -One `drive({ pollDeferred: true }, context)` performs at most one `Models.streamDeferred(handle, { wait: 0 })` poll; `resume(context)` is the convenience call that supplies that permit. `Models.streamDeferred` preserves the provider event stream required for frame persistence, while `Models.fetchDeferred` remains its result-only convenience and is not the harness runtime path. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A `deferred` response has a valid handle only when `message.deferred` exists, its id is non-empty, and its `{ provider, modelId, api }` equals the captured request model identity/API. An invalid initial handle is normalized to a durable `error` response and response-provenance `failure_drain`. A later pending response becomes the next source. Response, usage, `latestAssistantEntryId`, and failure state commit atomically. - -A deferred poll that returns an event stream persists frames exactly like assistant generation, at `pendingAssistantFrames(operationId, responseEntryId)` for the poll's reserved response id (§3.7). Every row below that inserts a response entry additionally deletes that response's frame list in the same transaction. Frames never change poll-number rules. - -The complete transition table — every row is one `commit()`; classification order (§3.7) applies to every poll settlement, cancellation first: - -| From | Trigger | Transaction | To | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| assistant `effect_pending` | settlement classifies `deferred` with a valid handle | §3.7's deferred row | suspended, `poll: 0`, `sourceEntryId: R` | -| suspended, poll _k_ | captured model is unavailable when a drive supplies `pollDeferred: true` | `TX[ S(failure_drain{error:{code:"model_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — the handle is abandoned without fabricating a fetch response or usage | failure_drain | -| suspended, poll _k_ | captured model resolves and a drive supplies `pollDeferred: true`: the poll's `before_request` settlement commits its intent, consuming the pass's single poll permit | mint fresh R′ and U′, then `TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]` | effect_pending, poll _k_+1 | -| effect_pending, poll _k_+1 | fetch returns **pending** | `TX[ insert response entry R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, deferred{suspended, sourceEntryId R′, poll k+1}) ]` — the pending response becomes the next source and the operation re-suspends; no second poll this invocation | suspended, poll _k_+1 | -| effect_pending | fetch returns **ready** with tool calls | `TX[ insert response R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, tools{plan with reserved result ids}) ]` — result ids minted as followers of R′ (§1.2) | tools | -| effect_pending | fetch returns **ready** without tool calls | `TX[ insert response R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | -| effect_pending | fetch settles as a provider `error` | `TX[ insert response R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` — polls have no retry path | failure_drain | -| effect_pending, restored, running control | crash left the poll's outcome unknown; a drive with a poll permit finds the captured model unavailable | `TX[ delete list pi.pending.assistant_frame/O:R′, S(failure_drain{error:{code:"model_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — abandon the old reserved response/usage strings without fabricating settlement | failure_drain | -| effect_pending, restored, running control | crash left the poll's outcome unknown; the next drive with a poll permit resolves the captured model and replaces it | mint fresh R″/U″ and commit a fresh intent at the **same** poll number — an unknown-outcome poll never completed, so `poll` does not increment; the old reserved id strings are abandoned, never materialized, and the intent transaction deletes the abandoned old frame list; the replacement poll starts a fresh list under its fresh response id | effect_pending, poll _k_+1 | -| effect_pending, cancelled control | reconciliation, live or restored (§4.5, §4.6) | synthetic settlement under the **existing** reserved ids, preserving partial content reduced from committed frames: `TX[ insert synthetic aborted response R′, insert zero usage U′, upsert pi.branch.tip = R′, delete frame list O:R′, S(latestAssistantEntryId=R′, cancelled checkpoint{may_finish}) ]` | cancelled checkpoint → aborted finish | -| suspended, cancelled control | reconciliation | no fetch starts; best-effort `cancel_deferred` targets the newest source (§4.6), and the operation finishes through the aborted terminal transaction | terminal | - -### Structural work - -```ts -type StructuralDecision = { taskId: string } & ( - | { status: "deciding" } - | { status: "generating"; generation: SummaryGeneration } -); - -interface SummaryContext { - taskId: string; - resultEntryId: string; - kind: "compaction" | "branch_summary"; - configuration: LaneConfiguration; - streamOptions: AgentHarnessStreamOptions; - retryPolicy: NormalizedRetryPolicy; - reason?: "manual" | "threshold" | "overflow"; +interface RunDeferredEffectPendingOperation extends RunScope, DeferredScope { + at: "run.deferred.effect_pending"; + responseEntryId: string; + usageId: string; } - -type SummaryGeneration = - | { status: "ready"; context: SummaryContext; nextAttempt: number } - | { status: "effect_pending"; context: SummaryContext; attempt: number; - /** Current nested request intent; absent between requests. */ - request?: { index: number; usageId: string }; - usageIds: string[] } - | { status: "retry_wait"; context: SummaryContext; nextAttempt: number; - notBefore: number; errorMessage: string }; - -interface CompactionState { - kind: "compaction"; - control: Control; - customInstructions?: string; - structural: StructuralDecision; -} - -type NavigationState = - | { kind: "navigation"; control: Control; targetId: string | null; label?: string; - summarize: false; phase: { kind: "ready_to_commit" } } - | { kind: "navigation"; control: Control; targetId: string; label?: string; - customInstructions?: string; summarize: true; - phase: { kind: "summary"; structural: StructuralDecision } }; ``` -Structural preparation is built from an observed source tip and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once to the `operationPreparation(operationId, taskId)` value before the decision hook, in the same transaction as the `deciding` state (§3.9). State carries only `taskId`; the deterministic constructor locates the value, and hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. +The canonical complete declarations live in `src/harness/session/types.ts`. Large stable payloads remain at referenced sibling addresses. State carries only the ids and bounded policy data required to dispatch and recover. -One structural attempt may make one or two sequential provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageId}`, invokes that request through `drive.gate.admit(...)`, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any activated orphaned `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. Never suspend between two requests of one live structural attempt: if its captured model identity disappears after request one, fail the attempt in-band so retry/failure follows the captured policy and no process-local intermediate text is parked waiting for registration. A durable `generating` decision prevents its decision hook from rerunning. +The value is authoritative after process loss, but it is not the finer instruction pointer of a live async procedure. A live assistant procedure commits `run.assistant.effect_pending` and retains its JavaScript continuation through request settlement. If that continuation is lost, the same durable leaf becomes the restart point for unknown-outcome recovery. ## 3.3 Lane state and the restore projection @@ -1513,7 +1398,7 @@ Attachment does not dereference transcript, queue payloads, deferred sources, as > Compute the next total state in memory, then atomically commit every entry insert, usage insert, and value/list write that makes that state true. -A transition plans from the authoritative owned projection supplied by the Session mutation line, verifies the semantic state it extends, and changes only the fields it owns. In particular, settlement preserves newer inbox/control fields, and the terminal transaction clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. A procedure that awaited external work rechecks its expected phase and effect identity against that current projection before committing. Cancellation routes to reconciliation; a missing or different operation means ownership was lost; any other unexpected phase or identity is an invariant defect (§4.3, §4.9). The callback reader is used only for referenced payloads and operation-owned cleanup scans. Every edge below is exactly one `commit()`. +A transition plans from the authoritative owned projection supplied by the Session mutation line and changes only the fields it owns. Settlement preserves newer inbox/control fields, and the terminal transaction clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. The live procedure is the only top-level state-advance writer, so its continuation does not revalidate operation identity or `at`; cancellation and inbox are the only supported orthogonal changes. The callback reader is used only for referenced payloads and operation-owned cleanup scans. Every edge below is exactly one `commit()`. ## 3.5 The graph @@ -1632,7 +1517,7 @@ The provider loop never awaits storage per frame; existing assistant event deliv At stream settlement, the procedure stops frame admission and awaits the latest frame-write promise before `after_response`; lane FIFO means its completion implies completion of every earlier append. There is **no** timer, batcher, coalescer, active/waiting state, or flush API — public or internal. A failed frame append faults the harness before `after_response` starts; the complete final response stays process-local and never commits after a storage fault. -Each append mutation verifies against the current owned projection that the same operation, attempt, and reserved response id still own the lane when it executes, and exact `Drive` identity is checked again at commit admission; a terminal transaction that won first fences a stale append without recreating operation state (§4.9). Frames are auxiliary: a missing list is valid, frames never prove request admission or completion, never select a restart point, and a crash after the final frame append but before settlement still restores `effect_pending` — a complete-looking draft is not a settled response. Structural summary streams persist no frames (§3.9). There is no frame-persistence event, and reconnect replays no historical updates (§5.4, §5.5). +Each append mutation verifies that the current leaf is assistant/deferred effect-pending under the same reserved response id. This is a child-progress fence: an append queued before settlement may commit first, while one that executes after the leaf changes declines without recreating the list. The lane-owned Drive is never replaced, so no top-level owner or exact-Drive check exists. Frames are auxiliary: a missing list is valid, frames never prove request admission or completion, never select a restart point, and a crash after the final frame append but before settlement still restores `effect_pending` — a complete-looking draft is not a settled response. Structural summary streams persist no frames (§3.9). There is no frame-persistence event, and reconnect replays no historical updates (§5.4, §5.5). ### Classification order @@ -1694,7 +1579,7 @@ Modes: Blocked and invalid calls skip intent/execution but still stage a synthetic outcome. A missing tool implementation is the ordinary unknown-tool case: stage an `isError:true` `ToolResultMessage` whose text says the named tool is unavailable, then continue the batch and later assistant turn. The harness constructs that message directly and omits `details`; it must not invent a value for the tool's application-defined typed details contract. A crash before staging reruns ordinary clearance, including `before_tool` under its replay contract. A crash after staging never reruns the hook or tool. -Calls are tracked internally by `sourceIndex`. Hooks and events see provider `toolCallId` and tool name. `AgentHarnessToolInvocation.invocationId` equals the reserved `resultEntryId`, remains stable across safe replay, and scopes durable memos under `operationToolMemo(O, invocationId, name)` addresses. Memo names must be non-empty and contain no `:`; `setMemo(name, undefined)` deletes. Memo operations synchronously enqueue on the Session mutation line before returning their promises, and tools must await writes. Each job verifies the same effect-pending invocation when it executes, protecting against authorized external finalization. A pre-return write is FIFO-ordered before staging and then deleted by staging; a post-return call rejects after capability expiry. No separate write drain exists. Flue-style `step.do(name, effect)` awaits these operations: a committed value returns on replay, while a crash before its memo commit may rerun the effect. There is no nested per-step replay state and no exactly-once external-effect promise. +Calls are tracked internally by `sourceIndex`. Hooks and events see provider `toolCallId` and tool name. `AgentHarnessToolInvocation.invocationId` equals the reserved `resultEntryId`, remains stable across safe replay, and scopes durable memos under `operationToolMemo(O, invocationId, name)` addresses. Memo names must be non-empty and contain no `:`; `setMemo(name, undefined)` deletes. Memo operations synchronously enqueue on the Session mutation line before returning their promises, and tools must await writes. Each job verifies the same effect-pending invocation when it executes, so a queued memo write cannot outlive outcome staging. A pre-return write is FIFO-ordered before staging and then deleted by staging; a post-return call rejects after capability expiry. No separate write drain exists. Flue-style `step.do(name, effect)` awaits these operations: a committed value returns on replay, while a crash before its memo commit may rerun the effect. There is no nested per-step replay state and no exactly-once external-effect promise. ## 3.9 Summary generation — compaction and navigation summaries @@ -1711,7 +1596,7 @@ Both operations generate a summary through the same `deciding → generating → | `threshold` | context-size check at a checkpoint | back to the stored `resumeAfter` | | `overflow` | a request that did not fit | `failure_drain` | -"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and the transition into `deciding` commit together (`upsert pi.op.preparation/O:{taskId}` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. +"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and the transition into `deciding` commit together (`upsert pi.op.preparation/O:{taskId}` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never enters a structural deciding leaf: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. | From | Trigger | Transaction | | -------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1895,7 +1780,7 @@ type LaneLastResult = )); ``` -A normal run finish copies `RunState.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Navigation records its source tip and, when it publishes one, the summary entry id, so a later matching `drive` can hydrate the complete public result after `pi.op.meta` has been deleted. Only terminal transitions construct a `LaneLastResult`. +A normal run finish copies `RunScope.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Navigation records its source tip and, when it publishes one, the summary entry id, so a later matching `drive` can hydrate the complete public result after `pi.op.meta` has been deleted. Only terminal transitions construct a `LaneLastResult`. Every terminal transaction, for every operation kind and outcome, has one shape: @@ -1948,139 +1833,44 @@ The invariant this section carries (restated in Part 9): operation-owned values ## 4.1 The live operation task -An open operation has durable state whether or not this process is executing it. A **live operation task** is only the process-local async continuation currently executing that operation. It is recorded because durable state cannot answer three process-local questions: whether this process already owns execution, whether an `effect_pending` state still has a live continuation, and what `abort(context)` should signal. - -Public operation status is a small process-relative observation, not a prediction of future identity availability: - -- `running`: this process has a live drive owner; -- `open`: durable work exists with no local owner; -- `aborting`: durable control is `cancel_requested` and cancellation reconciliation is next; -- idle: `current: null`; it is not an open-operation status. - -There is at most one process-local record per lane. It owns one `drive` pass, not the durable operation's whole lifetime: a pass may settle or reach a durable wait. Durable operation phase and control remain the recovery authority. Model/tool availability is checked only when a procedure reaches the corresponding effect-admission boundary; absence becomes an in-band durable error/result rather than a public operation status. +An open operation has durable state whether or not this process is executing it. A `Drive` is the lane-owned process-local continuation for one pass. It answers only whether this lane already has a live continuation, supplies the effect gate, and exposes one shared completion to callers. ```ts class Drive { readonly operationId: string; - readonly completion: Promise; // shared by installer and joiners + readonly completion: Promise; readonly gate: Gate; - readonly context: Context; // installer cancellation removed - readonly installerSignal: AbortSignal | undefined; + readonly context: Context; // installing invocation cancellation removed readonly waitForRetry: boolean; deferredPermits: number; - finalizedOutcome?: DriveOutcome; - passEnded: boolean; - // private GateControl, first-call-wins settle/fail closures, and hasSettled() } - -class AbortRequested extends Error { - readonly cancellation: Promise; - constructor(cancellation: Promise) { - super("Abort requested"); - this.cancellation = cancellation; - } -} - -class OperationEnded extends Error {} -class DriveAbandoned extends Error {} ``` -This is not another durable state machine. `Drive` is one installed process-local pass: shared completion, exact ownership identity, effect admission, invocation Context, and pass-local wait policy. Current durable state, retry data, responses awaiting settlement, structural intermediate content, and parallel tool promises remain ordinary procedure-local variables. Streaming drafts and running-tool display values remain lane fields used only for snapshots. +The first matching `drive` caller installs the Drive on the Session mutation line. Every later matching caller observes the same `Drive.completion`. The first caller is not an owner; after installation all callers are observation peers and the Lane owns execution. -`pi.op.state/{operationId}` is the operation's **durable restart point**, not the live JavaScript instruction pointer. While a task is alive, its call stack is the finer program counter: +Each caller races only its own observation with `context.abortSignal`. A signal that wins before installation starts nothing. A signal after installation rejects that caller's invocation but does not remove, replace, or cancel the Drive. Durable operation cancellation exists only through `requestAbort(operationId, context)`. + +One Drive is the sole top-level state-advance writer. Inbox methods mutate only inbox fields, `requestAbort` mutates only control, and close seals mutation admission. Therefore a live procedure's operation identity and `at` leaf cannot disappear or change concurrently. Procedures do not repeatedly verify operation existence, operation id, operation kind, Drive identity, or expected `at`. After awaiting external work they enter the Session mutation line and receive the latest authoritative `Lane.state`, preserving concurrent control and inbox changes. + +Parallel tool children are the exception: sibling call statuses genuinely race, so call identity/status checks and source-ready-prefix checks remain. + +The task runs direct async procedures. There is no graph interpreter or action scheduler. The Lane supplies two concrete mutation operations: + +- `advanceOperation` declines ordinary progress when current control is cancelled, pairs the next operation-state write with process-local projection publication, and otherwise exposes the procedure's writes/events; +- `mutateOperation` performs effect settlement and tool-child transitions against current control, and also owns the universal terminal suffix when the procedure returns a terminal decision. + +A pass ends at a terminal result or durable wait. `activeDrive` is then cleared by that pass; no live pass is replaced in-process. A process crash or close destroys/detaches the continuation, and a later attachment rebuilds `Lane.state` from durable values before another pass starts. + +Normal procedures remain straight line: ```text -live task: - JavaScript continuation currently awaiting this exact request - durable restart point assistant.effect_pending - -process lost: - JavaScript continuation gone - durable restart point assistant.effect_pending → unknown-outcome recovery +prepare +→ commit effect intent +→ admit and await external effect +→ commit output + next state ``` -That asymmetry is unavoidable around an external effect. Making the task explicit prevents normal execution from mistaking a live pending request for an orphaned one. - -The task runs direct async procedures. There is no generic planner, action interpreter, effect-plan graph, or storage-loading transition helper: - -```ts -async function executeDrivePass( - lane: Lane, drive: Drive, options: DriveOptions, -): Promise { - try { - let current = lane.state.operation; - if (current === null || current.meta.operationId !== drive.operationId) - return hydrateMatchingLastResult(lane, drive.operationId); - if (current.state.control.status === "cancel_requested") - return reconcileCancellation(current); - await runBeforeDrive(current); - - while (true) { - current = lane.state.operation; - if (current === null || current.meta.operationId !== drive.operationId) - return hydrateMatchingLastResult(lane, drive.operationId); - if (current.state.control.status === "cancel_requested") - return reconcileCancellation(current); - if (mustWaitForRetry(current, options)) return retryWait(current); - if (isDeferredSuspended(current) && !options.pollDeferred) - return deferredWait(current); - if (current.state.kind === "run" && current.state.phase.kind === "starting") - await settleBeforeRun(current); - else if (hasOrphanedEffect(current)) - await recoverOrphanedEffect(current, options); - else { - const result = await continueOrdinaryOperation(current, options); - if (result) return result; - } - } - } catch (error) { - if (error instanceof AbortRequested) { - await error.cancellation; - const current = lane.state.operation; - if (current === null || current.meta.operationId !== drive.operationId) - return hydrateMatchingLastResult(lane, drive.operationId); - return reconcileCancellation(current); - } - if (error instanceof OperationEnded) - return hydrateMatchingLastResult(lane, drive.operationId); - throw error; - } -} -``` - -The pseudo-code shows ownership and catch boundaries, not required helper names. `waitForRetry: false` returns the durable `notBefore`; `true` starts the existing abort-aware retry timer. `pollDeferred: true` grants at most the existing one-poll permit. `AbortRequested` is the one internal control-flow signal used when cancellation wins the effect gate. A procedure holding live local results catches it before the outer boundary when those values affect reconciliation. In particular, the parallel-tool procedure retains its promise map, stages each finalized started call as it settles, and materializes only the source-ready prefix; an assistant procedure parked after receiving a response retains that response long enough to settle it under cancelled control. - -Ordinary procedures are straight-line across their own durable restart points. Assistant generation is representative: - -```text -run before_request -→ commit assistant.effect_pending intent -→ invoke and await the provider -→ commit response + usage + next state -``` - -The procedure does not return to a dispatcher after its intent commit. Structural requests and deferred polls use the same shape. Parallel tool execution retains its local promises until every started call has either staged a finalized outcome or lost ownership; a separate Session mutation transition materializes ready outcomes in source order. - -Normal execution handles checkpoints, ready/retry states, planned or locally owned tools, outcome-ready materialization, structural decisions, deferred suspension, failure drain, and navigation commit. An unowned `effect_pending` state must not reach it. Such a state is handled only by orphan recovery (§4.5); reaching it from ordinary dispatch is an invariant defect. - -### Task installation, joining, and removal - -`accept` commits the first durable state and returns without installing a `Drive`. A durably accepted but locally unowned operation is ordinary. `drive({ operationId }, context)` owns one pass. On the Session mutation line it inspects the current owned projection, including its latest result, then applies exactly one rule: - -- current operation absent and `pi.lane.lastResult.operationId === operationId`: return its already-settled outcome; -- current operation absent with another/no last id, or current operation has another id: return `OperationMismatch` without starting work; -- the same operation already has a `Drive`: join that pass's completion; -- the same operation has no Drive: construct and install one before releasing the Session mutation line, then start the pass after `Session.mutate` returns. - -The first caller to install a pass supplies that pass's retry-wait and deferred-poll policy. Same-operation joiners observe the shared completion; a convenience caller whose policy requires more progress may call `drive` again after that pass returns. Installer cancellation abandons that shared pass, so installer and current joiners reject with `DriveAbandoned` and may drive the still-open durable operation again. A joiner's own cancellation ends only that joiner's observation. This arbitration lets `prompt` literally compose `accept` and `drive` even if an alarm reaches the accepted operation first, while preserving one process-local driver. Every drive is fenced by its expected operation id, so a stale wake for A never drives later operation B. - -A newly installed real pass checks cancellation, then invokes `before_drive` once before recovery or ordinary work. Joiners do not rerun it. A later pass after retry wait, deferred suspension, close/reopen, or process restart invokes it again. Durable phase and local ownership alone determine dispatch: `starting` requires `before_run`; an unowned `effect_pending` phase is orphaned; every other phase follows its ordinary procedure. No fresh/continue/restore marker exists. - -Every task has one outer `finally` path: after its last durable commit, wait decision, or terminal observation and final local events, it synchronously removes itself only when `lane.activeDrive === drive`. Normal completion settles only after that exact cleanup. Invocation abandonment and close/fault fail completion and remove the exact Drive immediately so non-cooperative work cannot block recovery or shutdown; every late planner and progress write also checks exact Drive identity. External finalization instead closes the gate in committed materialization and settles completion immediately after terminal event delivery without waiting for detached work. The exact Drive remains installed until both that settlement and pass ending have occurred; whichever side arrives second removes it. This preserves `LaneBusy` and same-id joining throughout event delivery and detached cleanup (§4.9). Once `finalizedOutcome` is set, the pass continuation may neither settle nor fail the shared completion; only the finalizer settles it after event delivery. - -Convenience methods add policy, not another execution path. `prompt`/`skill`/`compact`/`navigateTree` call `accept` and then drive the returned id; they wait locally through retry waits and return on terminal or deferred suspension. `resume(context)` inspects the current id and drives it with one deferred-poll permit. `abort(context)` calls `requestAbort`, then ensures cancellation reconciliation has a drive pass; it still returns once cancellation is durable. A scheduler calls the expected-id primitives directly and may persist the admission or set an alarm from `waiting.notBefore`. Normal applications do not manage operation ids when resuming. - -`AgentHarness.create(options, context)` restores the minimal projection and reports open operations but installs no tasks and starts no hooks, providers, tools, timers, or application callbacks. +Recovery dispatches directly from the flat `state.at` leaf. Cancellation reconciliation runs before ordinary dispatch and never starts new ordinary effects. ## 4.2 Effect gate @@ -2096,7 +1886,7 @@ interface Gate { interface GateControl { beginAbort(cancellation: Promise): void; signalAbort(): void; - close(error: HarnessClosed | HarnessFault | OperationEnded | DriveAbandoned): void; + close(error: HarnessClosed | HarnessFault): void; } ``` @@ -2113,7 +1903,7 @@ type GateState = `Gate.admit(invoke)` performs the only check and immediately returns `invoke()`. If abort is being committed it throws `AbortRequested(cancellation)`; if closed it throws the closing error. The gate owns the cooperative `AbortController`, exposed through `gate.signal`. -`requestAbort(operationId, context)` is the durable cancellation primitive. With a matching live Drive it creates the abort-mutation promise and calls `drive.beginAbort(promise)` synchronously before the lane mutation. A committed marker resolves that promise and then calls `drive.signalAbort()`. Ownership loss resolves it, closes the gate with `OperationEnded`, and returns `OperationMismatch`. Commit fault rejects it and closes with `HarnessFault`. With no Drive, requestAbort commits or observes the same durable marker but starts no pass. +`requestAbort(operationId, context)` is the durable cancellation primitive. With a matching live Drive it creates the abort-mutation promise and calls `drive.beginAbort(promise)` synchronously before the lane mutation. A committed marker resolves that promise and then calls `drive.signalAbort()`. An operation-id mismatch resolves it and returns `OperationMismatch`. Commit fault rejects it and closes with `HarnessFault`. With no Drive, requestAbort commits or observes the same durable marker but starts no pass. **The admitted-operation boundary is load-bearing.** Preparation finishes first; then the gate check and operation invocation are one synchronous expression: @@ -2175,7 +1965,7 @@ Lane commands, lane acquisition, progress writes, Branch creation/appends, metad `Session.mutate()` is a trusted sharp edge. Its callback must use the supplied mutator for bounded reads and its sole commit. Calling a public Session writer inside that callback queues the nested write behind the active callback; awaiting the nested write therefore deadlocks. Plugins must not perform nested public writes or unbounded work while holding the line. -A drive-owned command uses the current owned Lane projection for control flow and rechecks exact `lane.activeDrive === drive` immediately before commit admission. Settlement therefore preserves newer inbox/control fields and cannot write after owner abandonment. Providers, tools, hooks, timers, event delivery, idle waits, and Drive completion remain outside the line. +A Drive procedure uses the current owned Lane projection for control flow. The Lane pairs every operation-state write with publication of the matching process-local projection. Settlement therefore preserves newer inbox/control fields. Providers, tools, hooks, timers, event delivery, idle waits, and Drive completion remain outside the line. Raw Branch mutation while a Harness owns the corresponding AgentLane can stale the Harness projection and is a trusted-programming defect; AgentLane methods are the operation-aware surface during ownership. @@ -2245,7 +2035,7 @@ For a live operation, the first matching `requestAbort()` performs this order: `cancel_requested` or observes that the expected id no longer owns the lane; 3. for runs, atomically move current steer/follow-up ids to `control.drained*` and empty those inbox lists; their `pendingEntry(id)` values remain; -4. after a cancellation commit, resolve the promise and call `drive.signalAbort()`; after an ownership mismatch, resolve it and close the Drive gate with `OperationEnded`; +4. after a cancellation commit, resolve the promise and call `drive.signalAbort()`; after an operation-id mismatch, resolve it and return `OperationMismatch`; 5. return `AbortRequestResult` once cancellation is durable, or `OperationMismatch` when the fence failed; a matching live pass continues reconciliation. ``` @@ -2290,20 +2080,19 @@ For structural operations the atomic publication commit decides the race: cancel ## 4.7 Close — a controlled crash -Close is not abort and writes no cancellation or terminal state. It seals new `beginMutation()` admission and waits for every acquired mutation to reach `end()`: +Close is not abort and writes no cancellation or terminal state. It seals new Session mutation admission and waits for mutations admitted before the seal to finish: ```text -seal harness admission -→ close every live Drive gate -→ pull cooperative signals -→ reject local operation promises -→ let commits admitted before the seal drain -→ close storage and release the writer lease +seal harness and Lane admission +→ reject caller observations through the harness-close boundary +→ keep detached pass promises observed +→ drain admitted Session mutations +→ close storage ``` -A harness-wide admission barrier orders close against every operation and public commit. A commit admitted first may finish and close waits for it; close first prevents admission. A provider/tool result produced after sealing cannot commit. Tool checkpoint replacements and assistant frame appends already enqueued are ordinary admitted session work and may drain under the barrier; a request after sealing cannot commit, and live output newer than the last committed checkpoint or frame may be lost. Durable state therefore stops at the last committed restart point, often `effect_pending` with a committed frame prefix or bounded checkpoint, exactly as after process death. No synthetic response or cancellation marker is written. +A provider/tool result produced after sealing cannot commit because its next Lane mutation rejects with `HarnessClosed`. The Drive is not replaced and durable operation state is not changed. Reopening therefore sees the same restart point as process loss. -Reopening reports the open operation. A later matching `drive(options, context)`—direct or through `resume(context)`—applies orphan recovery when the durable phase requires it. No close-specific recovery path exists, and the durable aborted-implies-cancelled invariant remains true because close admits no locally aborted settlement under running control. +Whether a host also signals cooperative provider/tool work is local resource cleanup. It must not write cancellation, synthesize settlement, remove a durable operation, or create an ownership-loss recovery path. ## 4.8 Faults @@ -2311,14 +2100,6 @@ A failed admitted storage commit faults the whole harness. Fault closes Drive ga Close rejects active drive and convenience-operation promises with `HarnessClosed`; already-resolved admissions remain durable, while calls not yet accepted return `Err(Closed)`. Surfaces without a `Result` channel reject with `HarnessClosed` on and after close. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) faults the harness. `AgentTool.prepareArguments` is the deliberate exception normalized to a synthetic tool error. -## 4.9 External finalization - -An authorized administrative operation may commit the terminal transaction while a task still exists. In-process finalization must use the live owning `Lane`'s command path, on the same Session mutation line, so its terminal commit publishes the idle projection before notifying the owner. Bypassing that path and mutating reserved control values behind the live `Lane` is a programming defect. This specification adds no public `forceFinalize()` method: authorization, SLA policy, and initiation belong to repository or hosting administration, while terminal cleanup and live-task notification remain package-internal. Live admitted work receives `OperationEnded` when the finalizer closes `drive.gate`. Any queued or later planner observes ownership loss in the current projection, returns `lost_ownership`, and commits nothing. - -With a live in-process Drive, the finalizer itself records `drive.finalizedOutcome` and closes the gate in committed materialization, publishes and awaits the end observation, then settles shared completion without waiting for detached work; the detached task stops without publishing or writing. With no local Drive—for example, finalization by a replacement process—a later matching `drive()` hydrates from `pi.lane.lastResult`. Neither path recreates operation values/lists or commits a competing terminal result. Scalar and list invocation writes are fenced the same way: invocation memo writes, tool checkpoint replacements, and assistant frame appends each verify the current owned projection when their lane job executes and exact Drive identity at commit admission, so a write enqueued before finalization is deleted by the terminal cleanup (which removes all operation-owned values/lists) and a write after it declines without committing or recreating state. An unowned operation needs no task to stop; a later matching `drive()` returns the settled result from `pi.lane.lastResult`, while `resume(context)` sees an idle lane and returns `NothingToResume`. - -At most one terminal transaction commits because every candidate verifies the current owned projection on the Session mutation line before writing. An in-process finalizer records the matching result in `drive.finalizedOutcome` and closes its gate with `OperationEnded` in committed materialization, publishes and awaits the terminal event, then settles shared completion without waiting for non-cooperative provider/tool work. It retains the Drive until both terminal event delivery/settlement and the detached task's exact-object `finally` have occurred. Acceptance returns `LaneBusy`, and same-id drive callers join that retained completion, until the later boundary removes it. The task publishes no duplicate end event and cannot write after ownership loss. A replacement-process finalizer first acquires ownership after close/crash, so no live Drive exists to notify. - # Part 5 — Public surface ## 5.1 The lane surface @@ -2456,7 +2237,7 @@ interface WatchHandle { snapshot: T; start(listener: EventListener): void; un The convenience surface adds only invocation-lifetime policy: `prompt`/`skill`/`compact`/`navigateTree` call `accept` then drive the returned id with local retry waiting; `resume(context)` inspects and drives the current id with one deferred-poll permit; `abort(context)` requests durable cancellation and ensures reconciliation is driven. A serving layer may instead store `OperationAdmission`, return to its client, schedule from `waiting.notBefore`, and invoke the expected-id primitives later. -`getLastResult(context)` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads `laneLastResult(lane)` for the outcome its promise never delivered (§3.13). `inspectExecution(context)` enters a no-write lane job and reports the current small owned projection, local owner status, configured model identity, optional captured step identity, and latest result coherently. It does not resolve either identity through `Models` and performs no transcript/payload/frame reads. This lets a scheduler reconcile its own durable submission without an accept/settle race. Every state publication into that projection is serialized with the committing lane mutation. `pi.lane.lastResult` is still overwritten by the next terminal operation; permanent per-submission receipts remain a serving-layer concern. It is also how a caller learns the outcome of an operation finalized externally (§4.9). +`getLastResult(context)` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads `laneLastResult(lane)` for the outcome its promise never delivered (§3.13). `inspectExecution(context)` enters a no-write lane job and reports the current small owned projection, local owner status, configured model identity, optional captured step identity, and latest result coherently. It does not resolve either identity through `Models` and performs no transcript/payload/frame reads. This lets a scheduler reconcile its own durable submission without an accept/settle race. Every state publication into that projection is serialized with the committing lane mutation. `pi.lane.lastResult` is still overwritten by the next terminal operation; permanent per-submission receipts remain a serving-layer concern. `waitForIdle(context)` registers on the Session mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local idle callback owns the lane. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. @@ -3402,7 +3183,7 @@ Telemetry attributes may contain declared ids, names, counts, durations, statuse **This part is informative.** Nothing in it binds the shipping backends: Memory, JSONL, and SQLite never partition and never delete entries or usage rows (§1.2), and no core rule references this part for its correctness. It exists to show that the identity choices in §1.2 are sufficient for the one backend that would eventually retire old data — a possible Postgres deployment with TTL retention. It is a bridge we cross when we get there; this sketch is the current best guess, not a contract. - **The id is the partition key.** UUIDv7 sorts bytewise in time order, so the bulk tables — entries, usage ledger — use `PARTITION BY RANGE (id)` on the uuid id column, with period-boundary UUIDs (zeroed tails) as bounds. No partition column exists anywhere; §1.2's time prefix is the whole mechanism. Scalar values, `branch_meta`, stats, leases, and sessions stay in a hot unpartitioned catalog. `branch_entries` partitions by `entry_id` with the same bounds, so dropping a period cleans the branch index for free; `branch_meta` stays hot, and base pointers dangling into a dropped period are trimmed lazily on first access. -- **Pre-pass repair.** Before a period P is dropped, an online repairer makes live state stop referencing it: reparent edges crossing into P onto the nearest retained ancestor, found by an indexed uuid-range query; null any dormant `pi.branch.tip` decoding into P via a value-seq CAS; force-expire open operations still referencing P through values only — the terminal transaction of §3.13 writing `pi.lane.lastResult`, no synthetic entries, with any live task stopping through external finalization (§4.9); delete `pi.entry.label` values whose keys decode into P with one uuid-range delete. +- **Pre-pass repair.** Before a period P is dropped, an online repairer makes live state stop referencing it: reparent edges crossing into P onto the nearest retained ancestor, found by an indexed uuid-range query; null any dormant `pi.branch.tip` decoding into P via a value-seq CAS; force-expire open operations still referencing P through values only — the terminal transaction of §3.13 writing `pi.lane.lastResult`, no synthetic entries, after exclusive administrative ownership has been acquired; delete `pi.entry.label` values whose keys decode into P with one uuid-range delete. - **The commit barrier.** Repair races ordinary commits, so the final step is atomic against all of them: `BEGIN; LOCK entries, scalar_values IN ACCESS EXCLUSIVE MODE; ; ALTER TABLE … DETACH PARTITION p; COMMIT;` — plain `DETACH`, not `CONCURRENTLY`, precisely because it is transactional under the lock; the `DROP TABLE` happens later, unhurried. The barrier makes repair-plus-detach one linearization point: every commit sees either the fully attached period or a fully repaired store without it. - **The default partition.** A `DEFAULT` partition absorbs stray inserts whose ids predate every attached partition — an ancient `pendingNextRun` item consumed years after its mint still places under its reserved id and simply lands there. Nothing errors and nothing is lost; the default partition stays small and is never dropped. - **Value access under an external repairer.** A backend that admits an external repairer must perform value reads and CAS checks inside the commit transaction itself, so a repairer holding the barrier cannot interleave between a harness's read and its dependent write. The shipping backends need no such rule: single-writer sessions have no external repairer. @@ -3529,8 +3310,8 @@ These rows preserve requirements, not frozen package boundaries. Create their ha | R3 | **Generation recovery and retry** | Activation-only unknown-effect recovery that reduces committed frames into the synthetic zero-usage partial error under the reserved ids and then classifies ordinarily (retry or failure drain), non-retryable configuration failure when a later ready/retry attempt cannot resolve its captured model/tools, ordinary stop/error/deferred classification, provider-compliant `aborted`, failure-drain foundation, and both retry-wait policies: explicit drive returns `waiting/notBefore`, while convenience drive may own the abort-aware timer. Define deterministic timer and recovery-transition tests. Overflow classification remains explicitly unimplemented until R9. | Every generation state before/after reopen; accept/close/reopen/drive; recovery with no frames, partial frames, and authoritative end-frame content; synthetic settlement always commits under reserved ids with the interruption warning and deletes the frame list; interrupted partial tool calls never execute; `after_response` never runs for synthetic settlement; caps/backoff; no timer under `waitForRetry:false`; due and future `notBefore`; convenience/explicit-drive durable equivalence; timer abort-first/start-first; deterministic transition coverage; stop/error/aborted/deferred classification; non-retryable configuration unavailability. | | R4 | **Tools** | Add the harness-specific tool-update checkpoint options/callback while leaving pi-ai's `AgentToolUpdateCallback` unchanged, then compose §5.7's neutral tool phases with bound context, invocation-scoped memos, `pi.op.tool_args`, scalar `pendingToolOutput(operationId, invocationId)` checkpoints, `outcome_ready` staging in `pi.pending.entry`, completion-order outcome durability, source-ordered materialization, orphan recovery/replay, sequential/parallel promises, hook-blocked/genuine-length outcomes, hooks/events/usage, and deterministic transition coverage. Missing requested tools stage direct `ToolResultMessage` errors with no `details`; missing safe-replay implementations synthesize interruption rather than suspending. The built-in bash tool emits live updates at 100 ms and requests distinct checkpoints at most every two seconds. | Stable invocation id/memos across safe replay; every planned/effect-pending/outcome-ready/completed state; parallel out-of-order staging without replay; synchronous update callback with the latest delivery and latest checkpoint write awaited before `after_tool`; `tool_end` before staging; every requested checkpoint enqueued synchronously with only the latest promise reference retained and no write dropped or coalesced; tool-selected cadence/duplicate suppression; late-write fencing after settlement; JSONL checkpoint growth/compaction; safe replay deletes old checkpoint; unsafe recovery with/without checkpoint; missing planned tools become source-ordered error results; absent or no-longer-safe replay implementations synthesize interruption without invented typed details; source-ordered materialization; termination and abort-ready states. | | R5 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pendingEntry(id)` values, pending-payload consumption-time dereference, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with pending-value deletion, immediate total config setters, deferred tree writes, adjustments, and deterministic tests for the checkpoint boundaries introduced here. | Capture/cancel/consume races, repeated cancellation answering `not_found`, invalid queue payload kinds and missing pending values rejected at consumption, one-at-a-time crash after one drain, pending-value/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | -| R6 | **Abort, close, and failure drain** | Add expected-id `requestAbort` as commit/signalling only. Compose `abort()` from inspection, marker, and same-id drive. Implement orthogonal control, per-phase reconciliation including planned/effect-pending/outcome-ready tools, best-effort deferred cancellation, waiters/run-when-idle, controlled-crash close, complete terminal deletion of memos/checkpoints/staged outcomes, external-finalization stop, and deterministic transition coverage. | Cancellation with/without live pass; idempotency/fencing; abort at every tool state; restored interruption incorporates latest checkpoint without replay; restored cancellation synthesizes aborted responses preserving frame-reduced partial content and deleting the list; outcome-ready values survive and materialize; checkpoint/memo/frame cleanup; gate ordering; deferred cancellation; close races including a non-cooperative started tool that cannot block close and parallel retained promises that are all observed without later starts or unhandled rejection; external finalization fences late scalar/list writes; failure drain. | -| R7 | **Deferred provider redemption** | One poll per drive pass carrying `pollDeferred:true`; `resume()` composes inspection and that drive option. Add deferred consumption-time dereference and captured model resolution; unavailable models durably abandon redemption through configuration-provenance failure rather than waiting. Preserve copied configuration/options, per-poll hooks, source lineage, poll frame persistence with fresh intent after unknown poll deleting the abandoned frame list, ready tools, advancement of R6 cancellation, and deterministic poll-transition coverage. | `pollDeferred:false` returns waiting without work; one permit performs at most one poll; invalid or missing durable source handles; repeated pending, ready/error/aborted, crash positions, poll frame cleanup on every settlement, replacement, and configuration abandonment with no fabricated response/usage, unknown-poll snapshot without permit, convenience/primitive equivalence, no cap/backoff/loop, newest-handle cancellation. | +| R6 | **Abort, close, and failure drain** | Add expected-id `requestAbort` as commit/signalling only. Compose `abort()` from inspection, marker, and same-id drive. Implement orthogonal control, per-state reconciliation including planned/effect-pending/outcome-ready tools, best-effort deferred cancellation, waiters/run-when-idle, controlled-crash close, complete terminal deletion of memos/checkpoints/staged outcomes, and deterministic transition coverage. | Cancellation with/without live pass; idempotency/fencing; abort at every tool state; restored interruption incorporates the latest checkpoint without replay; restored cancellation synthesizes aborted responses preserving frame-reduced partial content and deleting the list; outcome-ready values survive and materialize; checkpoint/memo/frame cleanup; gate ordering; deferred cancellation; close races including a non-cooperative started tool; failure drain. | +| R7 | **Deferred provider redemption** | One poll per drive pass carrying `pollDeferred:true`; `resume()` composes inspection and that drive option. Add deferred consumption-time dereference and captured model resolution; unavailable models durably end redemption through configuration-provenance failure rather than waiting. Preserve copied configuration/options, per-poll hooks, source lineage, poll frame persistence with fresh intent after unknown poll deleting the abandoned frame list, ready tools, advancement of R6 cancellation, and deterministic poll-transition coverage. | `pollDeferred:false` returns waiting without work; one permit performs at most one poll; invalid or missing durable source handles; repeated pending, ready/error/aborted, crash positions, poll frame cleanup on every settlement, replacement, and configuration failure with no fabricated response/usage, unknown-poll snapshot without permit, convenience/primitive equivalence, no cap/backoff/loop, newest-handle cancellation. | | R8 | **Manual compaction** | Add compaction `OperationRequest` acceptance without task installation and compose `compact()` from accept+drive. Implement unowned preparation with final-command source revalidation, `pi.op.preparation/{opId}:{taskId}`, total structural state with consumption-time dereference, in-band structural model-unavailability failure, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort, and deterministic structural-transition coverage. | Explicit accept+drive versus convenience equivalence; empty/stale-preparation race; accept/close/reopen/drive; invalid task/preparation/source relationships; hook decline/result; crash after request one of split-turn generation; every state/crash; no public summary-stream messages and no structural frame persistence. | | R9 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure, and deterministic tests for added boundaries. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | | R10 | **Navigation** | Add navigation `OperationRequest` acceptance without task installation and compose `navigateTree()` from accept+drive. Implement navigation source/target/summary consumption-time dereference, validation, summarized decision/generation, one final transaction combining move/summary/tip/label with terminal writes, summary-only navigation hook, and deterministic transition coverage. | Explicit accept+drive versus convenience equivalence; root/current/unknown rejection; invalid durable source/target/summary combinations; accept/close/reopen/drive; summarized/unsummarized paths; final tip at summary; abort race; exact atomic publication including cleanup. | @@ -3569,9 +3350,9 @@ Operations: 17. At most one operation is open per lane. Two is corruption. 18. `overflowRecoveryUsed` is `true` only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes `false`; an unprojected custom write preserves it. 19. **The settlement transaction that commits a response with `stopReason: "aborted"` must, in that same transaction, write an operation state with `control.status === "cancel_requested"`.** The invariant is scoped to the committing transaction — later terminal cleanup or forks may remove the state without violating it. Providers must comply with the harness-owned signal contract; violation is corruption. -20. Attachment restores and validates only the small lane/operation projection (§3.3, §4.4). That owned projection is authoritative until close, fault, or process loss. Detailed presentation references are validated by `watch(context)` under the Session mutation line; drive payload references are validated by their consuming procedure. Missing or contradictory required data faults that consumer, while optional frame/checkpoint absence is legal. Live phase/effect-identity and exact-`Drive` checks remain concurrency fences. `pi.lane.lastResult` never determines an open operation's next procedure. -21. At most one terminal transaction ever commits per operation. In-process finalization closes the live Drive gate with `OperationEnded` and records `drive.finalizedOutcome` in committed materialization, then settles after terminal event delivery without waiting for detached work; once that outcome is recorded, the pass continuation may neither settle nor fail shared completion. The exact Drive is removed only after both finalizer-owned settlement and pass ending, so no caller hydrates before terminal event delivery. Queued/later planners return `lost_ownership` and commit nothing. With no local Drive, later matching callers hydrate from `pi.lane.lastResult` (§4.9). -22. At most one `Drive` exists per lane. Acceptance and taskless `requestAbort` never install one. A matching `drive` installs it before releasing the Session mutation line; another matching drive joins that pass, and a stale id starts nothing. Every drive-owned write rechecks exact Drive identity synchronously at commit admission. Normal `finally` removes only itself; abandonment and close/fault remove immediately; external finalization settles after terminal event delivery and retains ownership until both settlement and `finally` have occurred. Each newly installed pass invokes `before_drive` once after the cancellation check; joiners do not. `starting + cancel_requested` invokes neither `before_drive` nor `before_run`. +20. Attachment restores and validates only the small lane/operation projection (§3.3, §4.4). That owned projection is authoritative until close, fault, or process loss. Detailed presentation references are validated by `watch(context)` under the Session mutation line; drive payload references are validated by their consuming procedure. Missing or contradictory required data faults that consumer, while optional frame/checkpoint absence is legal. Top-level operation state has one live writer; only parallel tool-call status and queued progress/memo writes require child-state fencing. `pi.lane.lastResult` never determines an open operation's next procedure. +21. At most one terminal transaction commits per operation. The one lane-owned Drive is the sole top-level state-advance writer, and every terminal candidate serializes on the Session mutation line. Administrative mutation of a live Lane's reserved control values is unsupported; offline administration first acquires exclusive Session ownership. +22. At most one `Drive` exists per lane. Acceptance and taskless `requestAbort` never install one. A matching `drive` installs it before releasing the Session mutation line; another matching drive joins that pass, and a stale id starts nothing. Caller cancellation ends only that caller's observation. A live Drive is never replaced in-process. Close/fault seal mutation admission and reject observations without writing operation state. Each newly installed pass invokes `before_drive` once after the cancellation check; joiners do not. `run.starting` under cancelled control invokes neither `before_drive` nor `before_run`. 23. The §4.2 `Gate.admit()` catalog is complete. Every listed hook/provider/tool/timer integration calls `admit(() => operation())` after preparation; no unlisted code calls it. Admitted asynchronous provider setup/delegation owns `drive.gate.signal`. 24. `drive` and `requestAbort` are fenced by expected operation id. They may affect only that current operation; `drive` may also return its matching latest terminal result. A stale wake for A cannot drive or cancel B. 25. No public drive option encodes a wall-clock budget or partial-progress return. An admitted effect settles normally or is recovered from durable state after task loss; host scheduling and process termination remain outside the harness contract. @@ -3581,9 +3362,9 @@ Operations: 29. Every outcome-ready call has exactly one matching finalized `pi.pending.entry`, no immutable result entry, no invocation memos, and no tool-output checkpoint. Outcome-ready and completed calls never execute again. 30. A tool progress checkpoint is an optional bounded complete `AgentToolResult` snapshot, selected with `checkpoint:true`. It never proves completion. Every selected checkpoint synchronously enqueues one invocation-fenced scalar replacement; no write is dropped or coalesced, only the latest write promise reference is retained, and awaiting it implies completion of every earlier write. Staging or terminal cleanup deletes the value and fences late recreation. 31. Scalar assistant/deferred state is the sole restart authority for streamed partials. One effect-pending response id constructs exactly one `pendingAssistantFrames(operationId, responseEntryId)` address; every element is an exported pi-ai `AssistantMessageFrame`; frame order is a subsequence of provider event order because already-covered queued events produce no frame; terminal `done`/`error` events are never stored; frames never establish provider completion or suppress unknown-outcome recovery. -32. Every final or synthetic response settlement — normal, recovery, cancellation, or external finalization — atomically deletes its exact frame list. Idle forks contain no frame lists. A restored partial may appear in `streamingMessage` but never in `transcript` before settlement. +32. Every final or synthetic response settlement — normal, recovery, or cancellation — atomically deletes its exact frame list. Idle forks contain no frame lists. A restored partial may appear in `streamingMessage` but never in `transcript` before settlement. 33. The provider loop never awaits storage per frame; frame appends are enqueued synchronously in provider-event order, and awaiting the latest frame-write promise at stream settlement implies every accepted append completed. -34. Successful attachment publishes only complete lane projections and an open-operation inventory. It resolves no model/tool identity and starts no work. A later drive fences the authoritative owned projection and exact `Drive` identity; storage reads only dereference payloads named by that projection. +34. Successful attachment publishes only complete lane projections and an open-operation inventory. It resolves no model/tool identity and starts no work. A later drive uses the authoritative owned projection; storage reads only dereference payloads named by that projection. 35. Every event-producing committing harness lane job publishes its owned projection and calls `emitBatch` with its complete event batch in the exact continuation that observes commit, as the callback's final action; this includes AgentLane appends, lane and metadata setters, acceptance, and AgentLane acquisition/attachment. The mutation never awaits delivery, but the public operation does. A lane watch registers buffering and clones live presentation synchronously, then performs bounded durable reads while holding the line. Snapshot plus buffered events has no gap or duplicate and replays no pre-registration lifecycle. `emitBatch` binds recipients and the emitting Context immediately; a delayed watcher receives the object-identical source Context, never its start Context. 36. Shared Harness/AgentLane/Session/Branch receivers retain no invocation Context and expose no receiver-level telemetry default. Concurrent calls preserve independent telemetry and cancellation lineage. Context and its values are neither durable operation data nor serialized business arguments. RPC cancel/disconnect reaches only the matching invocation through `context.abortSignal` and never becomes durable cancellation. 37. Process-local model/tool registry absence never becomes durable waiting state or an acceptance error. Pre-intent request-configuration absence fails in-band without fabricating a response/usage; missing requested tools stage `isError` tool-result messages with no invented details; uncertain effects settle under their existing recovery rules first. @@ -3591,7 +3372,7 @@ Operations: ## 9.2 Race catalog -Each durable mutation race has exactly two durable histories. Process-local ownership races additionally have the one-driver/join or fence outcome shown below. Test every listed order with test-only commit gating and controlled hooks, providers, tools, and timers. +Each durable mutation race has exactly two durable histories. Matching callers install or join one lane-owned Drive; stale operation ids are rejected. Test every listed order with test-only commit gating and controlled hooks, providers, tools, and timers. | Race | Orders | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -3603,9 +3384,7 @@ Each durable mutation race has exactly two durable histories. Process-local owne | `abort` vs started tool outcome staging | abort first → real result stages under cancelled control; outcome first → finalized result is preserved and later materializes | | checkpoint vs tool settlement | every accepted checkpoint was enqueued before settlement closed admission; settlement awaits the latest write, then staging deletes the scalar; a late update is fenced without committing | | assistant frame append vs response settlement | settlement awaits the latest frame write, then its transaction deletes the list; a crash between leaves the committed frame prefix under `effect_pending` | -| assistant frame append vs external finalization | append first → terminal cleanup deletes the list; finalization first → the fenced append declines without committing | | live update event vs its queued frame/checkpoint commit | either finishes first; events are observation, and reconnect uses only committed frames/checkpoints | -| external finalization vs memo/checkpoint mutation | mutation first → terminal cleanup deletes it; finalization first → owned-projection/exact-Drive check declines without committing | | later tool B settles vs earlier tool A | B stages outcome-ready immediately; tree placement waits for A | | `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | | `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | @@ -3620,8 +3399,7 @@ Each durable mutation race has exactly two durable histories. Process-local owne | watcher registration vs state publication | watcher first → old snapshot plus the complete buffered event batch; publication/`emitBatch` first → new snapshot without that old batch | | close vs attachment | create completes and publishes a fully open harness; or close/fault rejects attachment without a partial harness | | snapshot capture vs resume | capture first yields pre-resume snapshot plus events; resume publication first yields post-transition snapshot | -| external finalization vs attachment/inspection | finalization is visible as idle/latest result; or attachment sees the still-open operation and later Session mutation inspection observes publication | -| concurrent invocation contexts | each call/event/session write retains its own telemetry parent and abort signal; joiner cancellation ends only that observer; installer cancellation abandons the shared pass for current joiners but writes no durable cancellation | +| concurrent invocation contexts | each call/event/session write retains its own telemetry parent and abort signal; cancellation ends only that caller observation and writes no durable cancellation | | `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | ## 9.3 Test tiers @@ -3632,7 +3410,7 @@ For each recovery prefix: close, reopen, drive, and compare against uninterrupte One corruption assertion constructs an `aborted` response with running control directly and requires the consuming transition to reject it as an invariant defect. Provider conformance separately proves implementations emit `aborted` only for the supplied signal. -**Tier B — writer conformance.** Run the public harness against the instrumented-storage decorator: a spy wrapping `Storage.commit()` that records every transaction's writes in order. Assert exact write order and content against the Part 3 transaction tables and §5.5 ordering. Faux provider/tool/hook spies interleave starts/events with commits. This tier catches: effects before intent; failure to await the latest update delivery or latest checkpoint write before `after_tool`; a provider loop awaiting storage per frame; frame appends out of provider-event order or persisted for `done`/`error`; a settlement transaction missing its frame-list delete; `tool_end` emitted after rather than before outcome staging; missing response/usage settlement; checkpoint or frame writes after ownership loss; outcomes not staged before replay becomes impossible; out-of-order tree placement; result ids reserved late; invocation memos or staged/checkpoint/frame values leaked by outcome/terminal cleanup. +**Tier B — writer conformance.** Run the public harness against the instrumented-storage decorator: a spy wrapping `Storage.commit()` that records every transaction's writes in order. Assert exact write order and content against the Part 3 transaction tables and §5.5 ordering. Faux provider/tool/hook spies interleave starts/events with commits. This tier catches: effects before intent; failure to await the latest update delivery or latest checkpoint write before `after_tool`; a provider loop awaiting storage per frame; frame appends out of provider-event order or persisted for `done`/`error`; a settlement transaction missing its frame-list delete; `tool_end` emitted after rather than before outcome staging; missing response/usage settlement; checkpoint or frame writes after their child state settled; outcomes not staged before replay becomes impossible; out-of-order tree placement; result ids reserved late; invocation memos or staged/checkpoint/frame values leaked by outcome/terminal cleanup. **Tier C — deterministic interleavings.** Every race in §9.2 is exercised in both orders with test-only gated commits and controlled hooks, providers, tools, and timers. @@ -3669,11 +3447,11 @@ One corruption assertion constructs an `aborted` response with running control d | **Attachment** | Minimal lane/operation projection restore plus open inventory; it starts no execution. | | **Invocation Context** | Immutable explicit telemetry/cancellation lineage for one call. It is never durable state or a shared receiver default. | | **Acceptance** | The atomic creation of one durable operation by `accept`; it starts no driver. | -| **Drive pass** | One process-local ownership span created or joined by `drive`, ending at settlement or a durable wait. | +| **Drive pass** | One lane-owned process-local continuation installed or observed by `drive`, ending at settlement or a durable wait. | | **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | | **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | | **Operation state** | The complete durable state of one open operation — the `operationState(operationId)` value and restart point after task loss. | -| **Drive** | The one installed process-local lane pass: exact ownership identity, shared completion, Gate, invocation Context, and wait policy. | +| **Drive** | The one installed lane-owned pass: shared completion, Gate, cancellation-free execution Context, and wait policy. | | **Effect gate** | Process-local synchronous arbitration of ordinary operation admission against a cancellation request, paired with the operation's cooperative signal. | | **Reserved id** | An id minted before content exists. Tool-result ids progress from a string in state to staged pending content to an immutable entry. | | **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares one time prefix (§1.2). | @@ -3688,7 +3466,6 @@ One corruption assertion constructs an `aborted` response with running control d | **Invocation memo** | Tool-invocation-scoped durable current value used for replay-safe memoization; valid only while the call is effect-pending. | | **Terminal transaction** | The commit that deletes an operation's values/lists, writes `pi.lane.lastResult`, and clears `currentOperationId`. | | **Segment** | A branch-index range that references an older branch instead of copying it. | -| **External finalization** | A terminal transaction committed outside the live task. An in-process finalizer closes the gate in committed materialization, publishes once, then settles the live Drive from `finalizedOutcome` after event delivery without waiting for detached work; the exact owner remains installed until both settlement and pass ending, preventing early hydration; with no local Drive, later callers hydrate from `pi.lane.lastResult` (§4.9). | | **Precise rewrite** | The administrative copy-retained-and-swap rebuild of a session store — the sole sanctioned path that removes entries or usage rows (§2.9). | # Appendix B — Coding-agent v3-format compatibility diff --git a/packages/agent/docs/runtime-simplification.md b/packages/agent/docs/runtime-simplification.md new file mode 100644 index 000000000..9f34ddabe --- /dev/null +++ b/packages/agent/docs/runtime-simplification.md @@ -0,0 +1,344 @@ +# Existing AgentHarness runtime simplification + +## Scope + +Rework the real implementation under `packages/agent/src/harness/runtime/` and the canonical durable types in `packages/agent/src/harness/session/types.ts`. This is not the isolated scratch spike. + +Public drive remains disabled until the execution graph is total. Format 4 is still work in progress, so the durable type replacement requires no migration or compatibility representation. + +## Core model + +A Lane is one process-local actor over one durable lane projection. + +- `Lane.state` is authoritative for tip, configuration, current operation, control, inbox IDs, and latest result. +- Every supported mutation commits through the Session mutation line and publishes the matching `Lane.state` before releasing it. +- One lane-owned Drive is the sole writer that advances an operation state. +- Same-operation callers observe the same Drive. No caller owns it. +- Invocation cancellation stops only that caller's observation after Drive installation. +- `requestAbort` is the only durable operation cancellation. +- Close seals mutation admission. It neither mutates operation state nor replaces a Drive. +- Process loss destroys all live continuations; recovery starts from the durable state after attachment. +- Session reads during execution dereference content named by `Lane.state`; they never rediscover control state. + +## Durable state + +`OperationState` has one discriminator, `at`, with 22 direct leaves: + +- `run.starting` +- `run.checkpoint` +- `run.assistant.ready` +- `run.assistant.effect_pending` +- `run.assistant.retry_wait` +- `run.tools` +- `run.deferred.suspended` +- `run.deferred.effect_pending` +- `run.compaction.deciding` +- `run.compaction.ready` +- `run.compaction.effect_pending` +- `run.compaction.retry_wait` +- `run.failure_drain` +- `compaction.deciding` +- `compaction.ready` +- `compaction.effect_pending` +- `compaction.retry_wait` +- `navigation.ready_to_commit` +- `navigation.summary.deciding` +- `navigation.summary.ready` +- `navigation.summary.effect_pending` +- `navigation.summary.retry_wait` + +Shared run, structural, and navigation data is factored with intersections. `Control` remains orthogonal. `ToolBatch` remains a child state machine because parallel tool children genuinely mutate sibling call statuses concurrently. + +## State and content boundary + +Procedures never read these addresses to decide execution: + +- `laneState` +- `operationMeta` +- `operationState` +- `branchTip` +- `laneConfig` +- `laneLastResult` + +They use the current `Lane.state` supplied by the mutation line. + +Storage reads remain only for content and cleanup: + +- prompt, assistant, deferred-source, final-assistant, and completed-tool entries; +- compaction-bounded branch context; +- `pendingEntry` payloads; +- assistant-frame lists; +- tool arguments, memos, and checkpoints; +- structural preparations; +- staged tool outcomes; +- operation-owned prefix scans required for terminal cleanup. + +## Concurrency model + +### Operation advancement + +While a Drive procedure is alive, no supported concurrent actor can change or remove its operation state: + +- inbox methods change only inbox fields; +- `requestAbort` changes only `control`; +- close prevents later mutation admission; +- another same-operation caller joins the existing Drive; +- another operation cannot be accepted while the lane is busy; +- process crash removes the continuation itself. + +Consequently, ordinary procedure transitions do not recheck operation existence, operation ID, operation kind, `at`, attempt identity, or nested status. Those are procedure preconditions established by dispatch, not supported races. + +The remaining real races are: + +1. cancellation arriving before effect admission or before settlement; +2. inbox arrival before checkpoint routing or terminal finish; +3. parallel tool children staging and materializing sibling outcomes; +4. queued frame/checkpoint/memo writes racing effect settlement; +5. retry timers racing cancellation or close; +6. deferred permit consumption; +7. accept/claim serialization on the Session line. + +### Cancellation boundaries + +Cancellation is checked in three places only: + +1. the drive loop before ordinary dispatch; +2. the gate immediately before an external effect starts; +3. effect settlement, which sees current `control` and commits the appropriate cancelled result. + +An ordinary transition helper may centrally decline progress when current control is `cancel_requested`. Procedures do not repeat that branch manually. + +### Close + +Closing the harness: + +1. marks harness/lane admission closed; +2. seals and drains the Session mutation line; +3. rejects client observations through a harness-close observation promise; +4. keeps every detached pass promise observed so a later rejection is never unhandled; +5. closes the Session after admitted mutations drain. + +Close performs no durable write, installs no replacement Drive, and creates no ownership-loss state. A late effect may return, but its mutation is rejected with `HarnessClosed`. + +Whether close also signals process-local provider/tool work is a resource-cleanup policy, not durable state-machine behavior. Do not couple it to Drive replacement or recovery. + +## Small concrete mutation API + +Replace repeated `LaneCommand` ceremony in procedures with two concrete Lane operations. They are not a scheduler, graph, or action interpreter. + +### `advanceOperation` + +Used for ordinary non-terminal progress. + +- Enters the Session mutation line. +- Receives the current authoritative Lane projection and operation state. +- If control is cancelled, returns to the drive loop without invoking the semantic planner. +- The planner supplies procedure-specific writes, the next complete `OperationState`, materialization, and events. +- The helper appends the `operationState` write and publishes the matching process-local operation projection. +- It does not verify an expected state or `at` value. + +### `mutateOperation` + +Used after an admitted provider/tool/structural effect and for genuine parallel child transitions. + +- Enters the Session mutation line even when control is cancelled. +- Supplies current control/inbox fields and the process-local effect result to the semantic settlement planner. +- Atomically commits payload, usage, tip movement, cleanup, and the classified next state. +- Appends the canonical operation-state write and publishes the matching projection. +- When the planner returns a terminal decision, appends `laneLastResult`, idle `laneState`, and the idle process-local projection. + +The caller supplies the typed outcome, cleanup/publication writes, last result, and event. Terminal business decisions remain visible in the owning procedure. + +## Drive lifecycle + +Delete the installer-owned model. + +- Remove `installerSignal`. +- Remove `DriveAbandoned`. +- Remove `LostOwnership` and `lost_ownership` from procedure results. +- Remove exact-object ABA fencing and `commandDriveOwned`. +- Remove `finalizedOutcome` and planned external-finalization owner retention unless the Flue investigation establishes a concrete requirement. +- Keep `activeDrive` only as the lane's install/join slot. +- Installation transfers work to the lane. Every caller, including the installer, observes completion with its own invocation Context. +- A caller signal abort before installation installs nothing. After installation it only rejects that caller's observation. +- The Drive is removed after its pass settles or reaches a durable wait. No live pass is replaced in-process. + +`requestAbort` retains the two-part gate ordering: + +```text +beginAbort before cancellation mutation +commit cancel_requested +signalAbort after commit +``` + +This prevents a new effect from entering while the durable marker is being committed. + +## Checks that remain + +Do not remove validation of external or referenced content: + +- required entry existence and role; +- pending payload kind; +- deferred handle identity; +- provider stream protocol ordering; +- response stop-reason invariants; +- UUIDv7 follower timestamp parsing; +- configured model/tool availability; +- tool-call source index and staged result identity; +- parallel tool call status and ready-prefix placement; +- progress/memo invocation identity; +- retry timestamp and deferred permit arithmetic. + +These validate data or genuine child concurrency. They are not defensive revalidation of the operation state machine. + +## Staged implementation plan + +### Stage 1 — Canonical flat durable types + +Files: + +- `src/harness/session/types.ts` +- compile-only consumers in `src/harness/runtime/`, restore, conformance helpers, and focused tests +- `docs/harness.md` +- `docs/work-packages/05-direct-durable-drive.md` + +Actions: + +- Replace nested operation state declarations with the flat `at` union. +- Preserve every durable datum without compatibility aliases. +- Update pattern matching mechanically without changing behavior. +- Keep ToolBatch/ToolCall nested. +- Update normative documentation in the same change. + +Exit: `npm run check`; existing focused runtime tests pass; no `phase.kind`, generation `status`, deferred `status`, or structural decision `status` remains in canonical operation state. + +### Stage 2 — Add canonical transition operations + +Files: + +- `src/harness/runtime/lane.ts` +- `src/harness/runtime/types.ts` +- focused Lane tests + +Actions: + +- Add `advanceOperation` and `mutateOperation`, including the terminal-decision suffix. +- Pair every durable operation-state write with process-local projection publication in one implementation. +- Centralize ordinary cancellation diversion. +- Trust the dispatcher-established current leaf; perform no expected-state check. +- Keep procedure-specific writes and event builders visible at call sites. + +Exit: focused tests prove durable state and `Lane.state` remain identical after every helper commit. + +### Stage 3 — Convert starting, checkpoint, and assistant + +Files: + +- `runtime/drive/checkpoint.ts` +- `runtime/drive/generation.ts` +- `runtime/drive/recovery.ts` +- `runtime/progress.ts` + +Actions: + +- Remove repeated operation/null/kind/state checks and `same*` predicates. +- Convert ordinary progress to `advanceOperation`. +- Convert assistant settlement to `mutateOperation`. +- Keep only checkpoint inbox/finish races and progress-channel ownership checks. +- Fold assistant recovery into the effect-pending handler if that is smaller. + +Exit: no control-state storage reads; no repeated assistant state verification; focused generation tests pass. + +### Stage 4 — Convert deferred and tools + +Files: + +- `runtime/drive/deferred.ts` +- `runtime/drive/tools.ts` +- `runtime/progress.ts` + +Actions: + +- Share assistant/deferred response-entry, usage, and tool-plan construction where their semantics match. +- Preserve deferred permit and handle checks. +- Remove top-level operation-state revalidation. +- Preserve per-call tool status merging, completion-order staging, source-order placement, memo fencing, and progress fencing. +- Use one tool-batch procedure for live, recovery, and cancellation modes instead of separate ownership-result paths. + +Exit: tool status checks exist only for genuine sibling concurrency; focused deferred/tool/progress tests pass. + +### Stage 5 — Remove ownership-loss machinery + +Files: + +- `runtime/types.ts` +- `runtime/lane.ts` +- `execution/effect-gate.ts` +- all existing `runtime/drive/*.ts` +- associated focused tests + +Actions: + +- Remove `LostOwnership`, `commandDriveOwned`, exact Drive checks, `installerSignal`, `DriveAbandoned`, and `finalizedOutcome`. +- Make all drive callers observation peers. +- Add observation-only Context cancellation to Drive completion waiting. +- Keep `activeDrive` only for install/join arbitration. +- Remove ABA/replacement tests and replace them with install/join/observation-cancellation tests. + +Exit: grep finds no ownership-loss or installer-abandonment vocabulary in production runtime. + +### Stage 6 — Close and fault + +Files: + +- `runtime/harness.ts` +- `runtime/lane.ts` +- Drive observation helper +- lifecycle tests + +Actions: + +- Seal mutation admission and drain admitted mutations. +- Reject client observations on close/fault without replacing a Drive or changing durable operation state. +- Observe detached pass failures. +- Verify a late effect cannot commit after close. +- Decide separately whether to signal local effects for resource cleanup. + +Exit: close and process loss leave the same durable restart point; no close path writes cancellation or synthetic settlement. + +### Stage 7 — Finish WP05 on the simpler substrate + +Files: + +- `runtime/drive/structural.ts` +- `runtime/drive/reconcile.ts` +- `runtime/drive.ts` +- `runtime/lane.ts` public surfaces + +Actions: + +- Implement structural generation directly over flat leaves and the transition operations. +- Implement cancellation reconciliation as one flat-state switch. +- Implement the total drive switch as one `state.at` switch. +- Wire public claim/join/observation and convenience methods only after every leaf is total. + +External finalization is excluded unless the Flue investigation identifies a concrete, current caller that cannot be expressed through close, explicit abort, recovery, or offline administration. + +## Validation + +After every code stage: + +```bash +npm run check +``` + +Run each modified focused test file from the package root. Do not run the full Vitest suite directly. Public drive remains disabled until the final stage. + +Final audit: + +```bash +rg 'lost_ownership|LostOwnership|DriveAbandoned|commandDriveOwned|installerSignal|finalizedOutcome' packages/agent/src/harness +rg 'operationState\(|laneState\(|branchTip\(|laneConfig\(' packages/agent/src/harness/runtime/drive +``` + +The second audit may match write constructors, but no reader call may use those control addresses. diff --git a/packages/agent/docs/work-packages/05-direct-durable-drive.md b/packages/agent/docs/work-packages/05-direct-durable-drive.md index ca691153d..fa762cb87 100644 --- a/packages/agent/docs/work-packages/05-direct-durable-drive.md +++ b/packages/agent/docs/work-packages/05-direct-durable-drive.md @@ -1,1833 +1,378 @@ # WP05 — Direct durable drive -**Status: M5 complete; WP06 foundation landed before M4.** Independent Opus, GPT-5.6-Sol, and Fable reviews approved the authoritative-`Lane.state` design and M3 generation implementation with no blockers. M4 retry/deferred and M5 durable tool-batch implementations also passed independent Fable review with no blockers. +**Status: M5 complete; pre-M6 runtime simplification in progress.** -WP06 separates Session/Branch/AgentLane/AgentHarness, replaces keyed lane lines with one keyless Session mutation line, removes implicit main, and renames durable/public leaf fields to tip. Every M4–M8 instruction below is interpreted on that foundation; no compatibility aliases or Harness-as-main assumptions remain. +WP06's Session/Branch/Lane separation is already part of the foundation. Public drive remains disabled until structural execution, cancellation reconciliation, the total drive switch, and every public execution surface are complete. -Removes breakpoints/manual drive, then implements the complete durable execution graph. Public drive stays disabled until every reachable phase and reconciliation path exists. +Format 4 remains work in progress. The flat operation-state replacement requires no migration or compatibility decoder. -The review checkpoints at M1, M3, M5, M7 and the final review are mandatory process obligations independent of that status. +## 0. Mandatory reading ---- +Read completely before implementation work: -## 0. Mandatory reading order +1. `packages/agent/docs/harness.md` +2. `packages/agent/docs/runtime-simplification.md` +3. `packages/agent/src/harness/session/types.ts` +4. `packages/agent/src/harness/session/values.ts` +5. `packages/agent/src/harness/runtime/lane.ts` +6. `packages/agent/src/harness/runtime/types.ts` +7. every existing `packages/agent/src/harness/runtime/drive/*.ts` +8. `packages/agent/src/harness/runtime/progress.ts` +9. `packages/agent/src/harness/execution/{effect-gate,assistant,tools}.ts` +10. relevant focused runtime tests -Read completely, in this order, before editing anything. +Do not inspect Git history or removed runtime implementations. The current source and these documents are the only implementation inputs. -- Read **all** of `harness.md`, start to finish. Excerpts, searches, prior conversation, and this handoff are not substitutes. -- Treat the current `harness.md`, this handoff, and the current runtime/session/execution APIs as the only architectural sources of truth. Do not rely on any external prototype, archive, or prior conversation. -- Implement runtime **clean-room**: do not inspect Git history for deleted runtime implementations, and do not recover, copy, adapt, or consult removed runtime1 code as a reference. -- Reread each relevant current source file completely before changing it. +## 1. Runtime model -1. `packages/agent/docs/harness.md` — all parts, normative. -2. `packages/agent/src/harness/agent-harness.ts` — public types and interfaces. -3. `packages/agent/src/harness/session/types.ts` — durable types (`OperationState`, `RunState`, `RunPhase`, `Generation`, `Deferred`, `ToolBatch`, `ToolCall`, `StructuralDecision`, `NavigationState`, `LaneLastResult`, `SessionReader`, `Write`, `CommitResult`). -4. `packages/agent/src/harness/session/values.ts` — every typed address and write helper. -5. `packages/agent/src/harness/runtime/lane.ts` — `Lane`, `Lane.command`, `LaneCommand`, `accept`. -6. `packages/agent/src/harness/runtime/types.ts` — `Config`, `LaneState`, `LaneCommand`, `Drive`, `ProcedureResult`, and `SliceNotImplemented`. -7. `packages/agent/src/harness/runtime/harness.ts` — `Harness`, config store, `fault`, `close`. -8. `packages/agent/src/harness/runtime/restore.ts` — projection restore. -9. `packages/agent/src/harness/execution/effect-gate.ts`, `execution/assistant.ts`, `execution/tools.ts`. -10. `packages/agent/src/harness/hooks.ts`, `events.ts`, `context.ts`, `result.ts`, `telemetry.ts`. -11. `packages/agent/src/harness/types.ts` — `AgentHarnessTool`, `AgentHarnessToolInvocation`. -12. `packages/agent/src/harness/session/testing/storage-decorator.ts`, `instrumented-storage.ts`, `testing/index.ts`. -13. Tests: `test/harness/runtime/{accept,harness,lane,restore,watch}.test.ts`, `test/harness/runtime/test-utils.ts`, `test/harness/execution-primitives.test.ts`, `test/harness/types.test.ts`. +### Lane authority ---- +`Lane.state` is authoritative while a Harness owns its Session. It contains all orchestration state required to dispatch: -## 1. Module ownership and import direction +- tip; +- lane configuration; +- pending-next-run ids; +- latest terminal result; +- operation metadata; +- flat operation state, including control and run inbox ids. -``` -agent-harness.ts public types + constructor -> runtime/harness.ts -session/** durable storage + session imports no harness runtime module -execution/effect-gate Gate / GateControl / AbortRequested -execution/assistant provider streaming -execution/tools tool phase helpers -runtime/types.ts Config / LaneState / LaneCommand / Drive / ProcedureResult -runtime/progress.ts progress channels -> runtime/types, type-only runtime/lane -runtime/drive/*.ts procedures -> runtime/types, progress, type-only runtime/lane -runtime/drive.ts the switch -> runtime/drive/*, type-only runtime/lane (M7 only) -runtime/lane.ts Lane owner + public surfaces -> runtime/drive.ts (value import, M8) -runtime/harness.ts Harness -> runtime/lane.ts +Every supported mutation commits on the one Session mutation line and publishes the matching `Lane.state` before releasing the line. Drive procedures never reread `laneState`, `operationMeta`, `operationState`, `branchTip`, `laneConfig`, or `laneLastResult` from storage. + +`SessionReader` is used only to dereference content named by in-memory state and to enumerate operation-owned cleanup addresses: + +- tree entries and branch context; +- pending entry payloads; +- assistant frame lists; +- tool arguments, checkpoints, and memos; +- structural preparations; +- staged tool outcomes; +- cleanup-prefix scans. + +### Flat state + +`OperationState` has one `at` discriminator with 22 direct leaves. The canonical declarations are in `session/types.ts`. There is no nested operation-kind/phase/generation-status/deferred-status/structural-status dispatch hierarchy. + +`ToolBatch` and `ToolCall` remain a nested child state machine because parallel call children genuinely update sibling statuses concurrently. + +### One lane-owned Drive + +One lane may install one process-local Drive pass. + +- The first matching caller installs it. +- Later matching callers observe the same completion. +- No caller owns the Drive after installation; the Lane does. +- Invocation cancellation rejects only that caller's observation. +- An installed Drive is never abandoned or replaced in-process. +- `requestAbort(operationId)` is the only durable operation cancellation. +- A stale operation id returns `OperationMismatch` and affects nothing. + +`Drive.context` removes the installing invocation signal. Effects receive only the operation gate's signal composed with that cancellation-free Context. + +### Close + +Close is not abort. It seals mutation admission, rejects local observations with `HarnessClosed`, observes detached pass failures, drains mutations admitted before the seal, and closes the Session. It writes no cancellation marker or synthetic terminal state and never replaces a Drive. + +### No external finalization + +There is no live-process external-finalization path, `OperationEnded`, `finalizedOutcome`, ownership-loss result, or exact-Drive ABA fence. Administrative rewriting or expiration first acquires exclusive Session ownership with no live Harness. + +## 2. Procedure shape + +Live procedures are ordinary straight-line async functions: + +```text +prepare +→ commit intent +→ admit and await effect +→ commit settlement ``` -Rules: -- `drive.ts` and `drive/**` import the concrete `Lane` with `import type`; the edge erases, so `lane.ts` can value-import `drive.ts` without a runtime cycle. -- No `Deps`, scope, handle, supervisor, or capability interface. Procedures receive the concrete lane and one `Drive`; `drive.gate` is the sole admission path. -- Admission is always spelled `drive.gate.admit(…)`. There is no second alias for the gate. -- A module may only import modules that already exist at its own milestone. `drive.ts` therefore cannot be written before M7. -- No generic scheduler, effect combinator, action interpreter, or callback that hides a commit. -- Addresses are typed constructors from `session/values.ts`. Never string addresses. Never a `delete_prefix` write op — it does not exist in `Write`. +A procedure is the sole writer that changes its top-level `at` leaf. Inbox methods change only inbox fields; `requestAbort` changes only `control`. Therefore a live continuation does not repeatedly check operation existence, operation id, kind, or `at`. ---- +The Lane supplies two concrete operations: -## 2. Approved source shape +- `advanceOperation(state, planner, context)` — ordinary progress; if durable cancellation already won, return to the drive loop without invoking the planner; +- `mutateOperation(state, planner, context)` — effect settlement and parallel tool-child mutations; the planner always receives current control/inbox data. -**Approved** = the target structure; deviation needs a stated reason. -**Illustrative** = names/fields inside a body that may change while the structure holds. +Both pair a returned next `OperationState` with its durable `operationState` write and exact process-local publication. A terminal decision additionally appends the universal suffix: -### 2.1 Drive ownership, completion, and exact-object cleanup — approved +1. procedure-specific publication and cleanup writes; +2. `laneLastResult`; +3. idle `laneState` preserving `pendingNextRun`; +4. idle process-local projection; +5. terminal result and event materialization. -One installed process-local pass is one `Drive`. `Lane.activeDrive` is the ownership slot. The same object carries the shared completion, effect gate, invocation context, and pass-local wait policy; there is no handle, supervisor, scope, or separate capability object. +The state argument is a type capability established by dispatcher control flow. It is not compared with current state at runtime. A mismatch would be a programming defect, not a supported race. -```ts -// packages/agent/src/harness/runtime/types.ts -export class Drive { - readonly operationId: string; - readonly completion: Promise; - readonly gate: Gate; - readonly context: Context; // installer context with cancellation removed - readonly installerSignal: AbortSignal | undefined; // used only to abandon this installed pass - readonly waitForRetry: boolean; - deferredPermits: number; - finalizedOutcome?: DriveOutcome; - passEnded = false; +## 3. Remaining concurrency checks - private readonly control: GateControl; - private settled = false; - // private resolve/reject closures omitted +Keep checks only where another supported writer can change the relevant fact: - constructor(options: DriveOptions, context: Context) { - this.operationId = options.operationId; - this.installerSignal = context.abortSignal; - this.context = withoutAbortSignal(context); - this.waitForRetry = options.waitForRetry ?? false; - this.deferredPermits = options.pollDeferred === true ? 1 : 0; - // create observed completion and Gate/GateControl - } +1. `requestAbort` versus effect admission and settlement; +2. inbox arrival before checkpoint routing or terminal finish; +3. parallel tool-call statuses and source-ready placement; +4. queued frame/checkpoint writes versus settlement; +5. invocation memo/checkpoint calls versus effect completion; +6. retry timers versus cancellation/close; +7. deferred permit consumption; +8. accept/claim serialization; +9. external/provider/content validation. - settle(outcome: DriveOutcome): void { /* first call wins */ } - fail(error: unknown): void { /* first call wins */ } - hasSettled(): boolean { return this.settled; } - beginAbort(cancellation: Promise): void { this.control.beginAbort(cancellation); } - signalAbort(): void { this.control.signalAbort(); } - closeGate(error: Error): void { this.control.close(error); } -} +Do not reintroduce operation identity, expected-`at`, exact-Drive, or ownership-loss checks into ordinary transitions. + +`requestAbort` keeps the two-step gate order: + +```text +beginAbort before the cancellation mutation +commit cancel_requested +signalAbort after the commit ``` -Procedures receive the concrete lane and drive: +This prevents a new effect from entering while the durable marker is being committed. -```ts -async function runGeneration( - lane: Lane, drive: Drive, generation: Generation, -): Promise { /* straight-line procedure */ } -``` +## 4. Completed milestones -`drive/**` imports `Lane` with `import type`, so no runtime cycle exists. `Lane` is package-internal and is not exported from the package root. +### M0 — Withdrawn execution-step controls -Exact ownership and cleanup stay on `Lane`: +Complete. Breakpoints, manual drive, and drive deadlines are absent. -```ts -// packages/agent/src/harness/runtime/lane.ts -/** Package-internal so deterministic tests can install and replace exact owners. */ -activeDrive: Drive | undefined; +### M1–M2 — Foundations and terminal mechanics -isDriveActive(drive: Drive): boolean { - return this.activeDrive === drive; -} +Complete. Includes the split effect gate, deterministic gated storage, authoritative process-local Lane projection, commit-result usage totals, progress channels, terminal cleanup, and bounded latest-result hydration. -private removeDrive(drive: Drive): void { - if (this.activeDrive === drive) this.activeDrive = undefined; -} -``` +### M3 — Assistant generation and recovery -Every late transition and progress write checks the current operation in the authoritative `Lane.state` supplied by the Session mutation line and exact process identity. Exact identity is checked again synchronously at commit admission, immediately before `mutator.commit()`, with no `await` between the check and invocation. A planner-side check alone is insufficient because abandonment may replace the owner after an asynchronous planner returns. +Complete. Includes: -This prevents ABA writes: after invocation abandonment, a replacement `Drive` may own the same durable operation id; the old object still fails exact identity and cannot write or remove the replacement. The small package-internal `Lane.commandDriveOwned(drive, plan, context)` variant performs only this admission check; it otherwise has the same visible `LaneCommand` decision as `command` and introduces no planner or transition framework. - -`startDrive` is ordinary Lane code: - -```ts -/** Package-internal M7 lifecycle seam; not part of AgentLane. */ -startDrive(drive: Drive): void { - const abandon = () => this.abandonDrive(drive, new DriveAbandoned()); - drive.installerSignal?.addEventListener("abort", abandon, { once: true }); - if (drive.installerSignal?.aborted) abandon(); - if (!this.isDriveActive(drive)) { - drive.installerSignal?.removeEventListener("abort", abandon); - return; // pre-aborted installer starts no pass work - } - - const pass = (async () => { - try { - return await runDrive(this, drive); - } finally { - drive.installerSignal?.removeEventListener("abort", abandon); - drive.passEnded = true; - // Normal passes remove before settlement. A finalized pass remains joinable until - // both its terminal event was delivered/settled and detached work ended. - if (drive.finalizedOutcome === undefined || drive.hasSettled()) this.removeDrive(drive); - } - })(); - pass.then( - (outcome) => { - // Once finalization records its outcome, the finalizer owns completion ordering: - // terminal event delivery first, then settle. The detached pass must not win. - if (drive.finalizedOutcome === undefined) drive.settle(outcome); - }, - (error) => { - if (drive.finalizedOutcome === undefined) drive.fail(error); - }, - ); - void pass.catch(() => {}); -} -``` - -Completion and owner removal are independent actions, but they need no supervisor object: - -| Path | Completion | Owner | -|---|---|---| -| normal return/rejection | pass calls `drive.settle` / `drive.fail` | pass `finally` removes exact drive first | -| external finalization | committed materialization records `finalizedOutcome` and closes the gate; after terminal event delivery, the finalizer settles without waiting for detached work | exact owner remains installed until **both** event-delivered settlement and pass ending have occurred; whichever happens second removes it | -| installer abandonment | close gate and call `drive.fail(new DriveAbandoned())` immediately | remove exact drive immediately so a replacement pass can recover `effect_pending` | -| close/fault | close gate and fail completion immediately | remove exact drive; admission is already sealed | - -Omitting removal is the retention operation; there is no no-op `retainOwner()` method. - -Install and join share `drive.completion`. Therefore installer abandonment abandons that shared pass and every current joiner receives `DriveAbandoned`; durable state remains at its last commit and any caller may drive it again. A joiner's own invocation cancellation ends only that joiner's observation and does not abandon the installed pass. The retained pass context is cancellation-free; `installerSignal` is observed separately for installer abandonment. - -An in-process finalizer records `drive.finalizedOutcome` before closing the gate in committed materialization, delivers its terminal event, then settles the Drive without waiting for detached work. `runDrive` catches `OperationEnded` and returns that outcome without entering another lane job or publishing another end event. A replacement-process finalizer has no local drive and hydration uses `laneLastResult`. - -### 2.2 Gate / GateControl — approved - -```ts -// packages/agent/src/harness/execution/effect-gate.ts -export class AbortRequested extends Error { /* unchanged: carries `cancellation` */ } - -/** Invocation cancellation or owner abandonment. Never durable cancellation. */ -export class DriveAbandoned extends Error { - constructor() { - super("Drive pass was abandoned by its installing invocation"); - this.name = "DriveAbandoned"; - } -} - -export interface Gate { - /** check(); return invoke(); — no await may appear between them. */ - admit(invoke: () => T): T; - readonly signal: AbortSignal; -} - -export interface GateControl { - beginAbort(cancellation: Promise): void; - signalAbort(): void; - close(error: Error): void; -} - -type GateState = - | { status: "open" } - | { status: "aborting"; cancellation: Promise } - | { status: "closed"; error: Error }; - -export function createGate(): { gate: Gate; control: GateControl } { - let state: GateState = { status: "open" }; - const controller = new AbortController(); - - // The only check. A closure, not a method: there is no assertOpen() to call - // at the wrong distance from its call. - const check = (): void => { - if (state.status === "aborting") throw new AbortRequested(state.cancellation); - if (state.status === "closed") throw state.error; - }; - - return { - gate: { - admit(invoke: () => T): T { - check(); - return invoke(); - }, - signal: controller.signal, - }, - control: { - beginAbort(cancellation) { - if (state.status !== "open") return; - state = { status: "aborting", cancellation }; - }, - signalAbort() { - if (state.status !== "aborting" || controller.signal.aborted) return; - controller.abort(new AbortRequested(state.cancellation)); - }, - close(error) { - state = { status: "closed", error }; - if (!controller.signal.aborted) controller.abort(error); - }, - }, - }; -} -``` - -Invocation-cancellation policy (approved): - -| Event | Action | -|---|---| -| installing invocation aborted **before** any admit | nothing starts; no durable write; pass abandoned | -| installing invocation aborted **after** intent/admission | `abandonDrive(drive, new DriveAbandoned())` (M7): closes the gate, fails the shared pass completion for installer and current joiners, and removes the owner immediately — independent of the pass, so the next drive can claim the lane even if the old effect never returns. Durable state stays at its last commit (`effect_pending`) and that next drive recovers it as an orphan | -| joiner invocation aborted | only that joiner's observation ends | -| `requestAbort` | `beginAbort` → interrupt → commit `cancel_requested` → **then** `signalAbort()` | - -`DriveAbandoned` never writes `cancel_requested`, never commits an aborted settlement, never calls `requestAbort`. -The retained pass context is `withoutAbortSignal(installerContext)`; admitted effects compose `withAbortSignal(gate.signal, …)`. - -### 2.3 Lane.drive claim / join / hydration — approved - -```ts -// packages/agent/src/harness/runtime/lane.ts -type DriveClaim = - | { kind: "join"; drive: Drive } - | { kind: "install"; drive: Drive } - | { kind: "settled"; outcome: DriveOutcome } - | { kind: "mismatch"; error: OperationMismatch }; - -async drive(options: DriveOptions, context: Context): Promise { - context.abortSignal?.throwIfAborted(); // a pre-aborted invocation installs and starts nothing - if (this.closedError instanceof HarnessClosed) { - return Result.err(new Closed({ message: this.closedError.message })); - } - this.assertOpen(); - - const claim = await this.command(async (state, reader) => { - // 1. A matching local owner wins over owned-projection idle: after the terminal - // commit the lane is idle while the old owner is still delivering final events - // and removing itself. - const owner = this.activeDrive; - if (owner !== undefined && owner.operationId === options.operationId) { - return { kind: "return", result: { kind: "join", drive: owner } }; - } - - const currentId = state.operation?.meta.operationId ?? null; - const last = state.lastResult; - - // 2. Another operation is still owned locally. - if (owner !== undefined) { - return { kind: "return", result: { kind: "mismatch", error: this.mismatch(options.operationId, currentId, last) } }; - } - - // 3. Current operation matches and has no owner: install. - if (currentId === options.operationId) { - const drive = new Drive(options, context); - this.activeDrive = drive; - return { kind: "return", result: { kind: "install", drive } }; - } - - // 4. Idle and the latest result is ours: dereference only entries named by it. - if (currentId === null && last?.operationId === options.operationId) { - const outcome = await hydrateTerminalOutcome(reader, last, context); - return { kind: "return", result: { kind: "settled", outcome: { kind: "settled", operationId: options.operationId, outcome } } }; - } - return { kind: "return", result: { kind: "mismatch", error: this.mismatch(options.operationId, currentId, last) } }; - }, context); - - switch (claim.kind) { - case "mismatch": - return Result.err(claim.error); - case "settled": - return Result.ok(claim.outcome); - case "join": - // A joiner's invocation signal ends only its observation. - return awaitCompletion(observeCompletion(claim.drive.completion, context)); - case "install": - this.startDrive(claim.drive); // AFTER the line released - // Installer cancellation is translated by startDrive into shared DriveAbandoned. - return awaitCompletion(claim.drive.completion); - } -} - -function observeCompletion(completion: Promise, context: Context): Promise { - const signal = context.abortSignal; - if (signal === undefined) return completion; - if (signal.aborted) return Promise.reject(signal.reason); - return new Promise((resolve, reject) => { - const aborted = () => { - signal.removeEventListener("abort", aborted); - reject(signal.reason); // this joiner's observation only - }; - signal.addEventListener("abort", aborted, { once: true }); - completion.then( - (outcome) => { signal.removeEventListener("abort", aborted); resolve(outcome); }, - (error) => { signal.removeEventListener("abort", aborted); reject(error); }, - ); - }); -} - -/** Shared adapter from an observed completion to DriveResult. */ -async function awaitCompletion(completion: Promise): Promise { - try { - return Result.ok(await completion); - } catch (error) { - // Expected, and declared in DriveResult's error union. - if (error instanceof OperationMismatch) return Result.err(error); - if (error instanceof Closed) return Result.err(error); - // Not expected errors and not members of the union: HarnessClosed, HarnessFault, and - // DriveAbandoned reject the caller's promise unchanged. - throw error; - } -} - -private mismatch(expected: string, currentOperationId: string | null, last: LaneLastResult | undefined): OperationMismatch { - return new OperationMismatch({ - lane: this.name, - expectedOperationId: expected, - ...(currentOperationId === null ? {} : { currentOperationId }), - ...(last === undefined ? {} : { lastOperationId: last.operationId }), - message: `Operation ${expected} does not own lane ${JSON.stringify(this.name)}`, - }); -} -``` - -`accept` gains one guard (approved): while `this.activeDrive !== undefined`, return `LaneBusy` even when durable state is idle. Installer cancellation abandons the shared pass; joiner cancellation only ends that joiner's `awaitCompletion` observation. - -### 2.4 Drive switch contract — approved - -**Compile ordering.** `Drive` lives in `runtime/types.ts` from M1; M2 replaced the initial `StepResult` with `ProcedureResult`. `runtime/drive.ts` is created in M7, once every procedure module it imports exists. M2–M6 call procedures directly from tests with a concrete `Lane` and `Drive`; public drive stays guarded until M8. - -```ts -// packages/agent/src/harness/runtime/types.ts -export type LostOwnership = { kind: "lost_ownership" }; - -export type ProcedureResult = - | { kind: "continue" } - | { kind: "waiting"; outcome: DriveOutcome } - | { kind: "settled"; outcome: TerminalOperationOutcome } - | LostOwnership; -``` - -| Result | Obligation | -|---|---| -| `continue` | inspect the current `Lane.state` and continue the ordinary drive loop; this procedure either committed or observed a supported concurrent change since its pre-work snapshot | -| `waiting` | the operation is durably parked in retry or deferred wait | -| `settled` | this procedure's terminal transaction committed | -| `lost_ownership` | commit nothing; this task terminates and never writes again | - -There is no global `advance`/`reload` distinction. A procedure must not return `continue` from an unchanged state it does not understand; that is an invariant defect, not a spin-and-hope path. Whether a particular intent commit landed matters only to the straight-line procedure that is about to start its effect. Such a helper returns a small local result such as `"committed" | "state_changed" | LostOwnership`; it does not become drive-loop vocabulary. Tool children likewise use a tool-local result when the batch must know whether a child staged or materialized anything. - -```ts -// packages/agent/src/harness/runtime/drive.ts — M7 -export async function runDrive( - lane: Lane, drive: Drive, -): Promise { - try { - await runBeforeDriveUnlessCancelled(lane, drive); - for (;;) { - if (lane.closedError !== undefined) throw lane.closedError; - if (!lane.isDriveActive(drive)) throw new DriveAbandoned(); - const before = lane.state; - const operation = before.operation; - if (operation === null || operation.meta.operationId !== drive.operationId) { - return hydrateSettled(lane, drive); - } - if (operation.state.control.status === "cancel_requested") { - return { kind: "settled", operationId: drive.operationId, - outcome: await reconcile(lane, drive, operation) }; - } - const result = await dispatch(lane, drive, operation); - if (result.kind === "continue") { - if (lane.state === before) { - throw new SessionInvariantError("drive procedure returned continue without progress"); - } - continue; - } - if (result.kind === "lost_ownership") { - if (drive.finalizedOutcome !== undefined) return drive.finalizedOutcome; - if (lane.closedError !== undefined) throw lane.closedError; - throw new DriveAbandoned(); - } - if (result.kind === "settled") { - return { kind: "settled", operationId: drive.operationId, outcome: result.outcome }; - } - return result.outcome; - } - } catch (error) { - if (error instanceof OperationEnded) return hydrateSettled(lane, drive); - if (error instanceof AbortRequested) { - await error.cancellation; - const operation = lane.state.operation; - if (operation === null || operation.meta.operationId !== drive.operationId) { - return hydrateSettled(lane, drive); - } - return { kind: "settled", operationId: drive.operationId, - outcome: await reconcile(lane, drive, operation) }; - } - throw error; - } -} -``` - -The body is illustrative, not a required helper inventory. The unchanged-`continue` guard rejects the pass with `SessionInvariantError`; it is a liveness assertion outside `Lane.command`, not another transition or a storage reread. `drivePass`/`dispatch` inspect the authoritative `Lane.state`; they do not load lane or operation control values from storage. `hydrateSettled` first returns `drive.finalizedOutcome` when present; otherwise it uses matching `lane.state.lastResult` and dereferences only entries named by that result. A foreign current/latest operation produces `OperationMismatch` and no write. - -### 2.5 Authoritative lane state and owned commands — approved - -`Lane.command` passes the authoritative current lane/operation projection. Every supported control-state mutation commits through that `Lane`, and a successful commit publishes `decision.next` before releasing the mutation line. A failed commit publishes nothing and faults the harness. A process crash destroys the projection; attachment restores and validates it from durable control values. - -Procedures therefore do **not** reread `laneState`, `operationMeta`, `operationState`, `branchTip`, or `laneConfig` on each transition. They use the callback reader only to dereference content named by the projection: pending entries, assistant frames, tool arguments/checkpoints, preparations, memos, staged outcomes, tree entries, and cleanup-prefix scans. - -Drive-owned commands use one small `Lane` variant so exact-object ownership is checked at commit admission rather than only in an asynchronous planner: - -```ts -// packages/agent/src/harness/runtime/lane.ts -async commandDriveOwned( - drive: Drive, - plan: (state: LaneState, reader: SessionReader) => - LaneCommand | Promise>, - context: Context, -): Promise { - // Same implementation and visible LaneCommand decision as command(), with two additions: - // 1. before planning, state.operation must name drive.operationId and activeDrive must be drive; - // 2. for a commit decision, assertOpen() and activeDrive === drive are checked immediately - // before mutator.commit(), with no await between the checks and invocation. -} -``` - -`commandDriveOwned` is not a scheduler, planner, transaction wrapper, or capability facade. It is `Lane.command` plus the exact-Drive admission fence. If its initial operation/id check or final exact-object check fails, it returns `lost_ownership` and commits nothing. Close/fault is checked first at final admission and propagates its lifecycle error rather than being mislabeled as ownership loss. This extra final open check is drive-specific: exact owner abandonment may occur outside the Session line while an asynchronous planner runs. An ordinary `command` is admitted when its mutation callback starts and may finish that active commit after close begins. Ordinary public lane commands continue to use `command`. - -A representative transition stays direct: - -```ts -const intent = await lane.commandDriveOwned(drive, (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("generation intent reached a non-run operation"); - } - const run = operation.state; - if (run.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - if (run.phase.kind !== "assistant" || - run.phase.generation.status !== "ready" || - run.phase.generation.context.stepId !== expectedStepId) { - throw new SessionInvariantError("generation intent lost its ready boundary"); - } - const nextState: RunState = { ...run, phase: { kind: "assistant", generation } }; - return { - kind: "commit", - writes: [setValue(operationStateValue(drive.operationId), nextState)], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, - materialize: () => ({ kind: "committed" } as const), - events: () => [{ type: "turn_start", runId: drive.operationId, - turnId: expectedStepId, lane: lane.name }], - }; -}, drive.context); -if (intent.kind !== "committed") return intent.kind === "lost_ownership" - ? intent - : { kind: "continue" }; -// The effect may start only on this branch. -``` - -The transition derives `next` from the state supplied to that planner, never from a snapshot captured before the lane job. Inbox and other concurrent fields that do not invalidate this transition are preserved in the committed next state. Cancellation returns to reconciliation without committing; a newly non-empty inbox invalidates only a terminal-finish boundary; relevant tool-sibling movement uses the tool-local `state_changed` result. Ownership loss is handled in band. A matching operation with any other impossible kind/phase relationship is a `SessionInvariantError`. - -The single-owner contract is explicit: while a Harness owns a `Session`, raw Branch mutation for a configured AgentLane and reserved control-address writes are forbidden. In-process administrative finalization also commits through the owning `Lane`. External storage mutation is not a supported second authority, and the runtime does not silently heal it. Conformance tests compare each committed projection with a fresh restore at controlled boundaries. - -### 2.6 Generation procedure — approved shape, illustrative bodies - -```ts -// packages/agent/src/harness/runtime/drive/generation.ts -async function runReadyGeneration( - lane: Lane, drive: Drive, run: RunState, - gen: Extract, -): Promise { - const config = lane.readConfig(); - const identity = gen.context.configuration.model; - const model = lane.models.getModel(identity.provider, identity.modelId); - if (model === undefined) return enterConfigurationFailure(lane, drive, "model_unavailable", identity); - - // ---- prepare: pure/local, nothing reserved yet ---- - const messages = await buildRequestMessages(lane, drive, gen.context, run); - const systemPrompt = await resolveSystemPrompt(config, drive.context); - const patch = await lane.hooks.runWithGate( - "before_request", - { lane: lane.name, runId: drive.operationId, model, step: "assistant", - attempt: gen.nextAttempt, streamOptions: gen.context.streamOptions }, - drive.gate, drive.context, - ); - const streamOptions = applyStreamOptionsPatch(gen.context.streamOptions, patch?.streamOptions); - const at = Date.now(); - const responseEntryId = lane.session.idGenerator.next(at); - const usageId = lane.session.idGenerator.next(at); - - // ---- intent: one visible commit ---- - const generation: Generation = { - status: "effect_pending", context: gen.context, attempt: gen.nextAttempt, - responseEntryId, usageId, - intendedOutputLimit: model.maxTokens, contextWindow: model.contextWindow, - }; - const intent = await commitGenerationIntent(lane, drive, gen.context.stepId, generation); - if (intent.kind === "lost_ownership") return intent; - if (intent.kind === "state_changed") return { kind: "continue" }; - - // ---- effect: admitted, off the Session line ---- - const progress = openFrameProgress(lane, drive, responseEntryId); - let response: SettledAssistantMessage; - try { - response = await streamHarnessAssistant(messages, { - model, systemPrompt, thinkingLevel: gen.context.configuration.thinkingLevel, streamOptions, - toProviderMessages: config.toProviderMessages, - request: (aiContext, options) => - drive.gate.admit(() => lane.models.streamSimple(model, aiContext, options)), - observer: frameObserver(lane, drive, progress, responseEntryId), - }, withAbortSignal(drive.gate.signal, drive.context)); - } finally { - progress.seal(); - } - await progress.drain(); - - // ---- outcome: one visible commit ---- - return settleAssistant(lane, drive, - { responseEntryId, usageId, stepId: gen.context.stepId }, response); -} -``` - -Preparation precedes the intent commit; settlement writes under the reserved ids; the frame observer enqueues synchronously and never awaits storage; `progress.drain()` precedes settlement. `commitGenerationIntent` has a small local result because this procedure must know whether it may start the effect. Successful settlement returns `continue`, `waiting`, `settled`, or `lost_ownership` according to the state it committed; there is no global commit-count result. - -### 2.7 Progress helper — approved - -```ts -// packages/agent/src/harness/runtime/progress.ts -export interface ProgressChannel { - write(item: T): void; - seal(): void; - drain(): Promise; - clearWrite(): Write; -} - -function openProgress( - lane: Lane, drive: Drive, - commitWrite: (item: T) => Write, - clear: () => Write, - stillOwns: (state: LaneState) => boolean, -): ProgressChannel { - let sealed = false; - let latest: Promise = Promise.resolve(); - return { - write(item) { - if (sealed) return; - const write = lane.commandDriveOwned(drive, (state) => { - if (!stillOwns(state)) return { kind: "return", result: undefined }; - return { kind: "commit", writes: [commitWrite(item)], next: state, - materialize: () => undefined }; - }, drive.context).then(() => undefined); - latest = write; - void write.catch(() => {}); - }, - seal() { sealed = true; }, - async drain() { await latest; }, - clearWrite: clear, - }; -} -``` - -`openFrameProgress(lane, drive, responseEntryId)` and `openToolProgress(lane, drive, invocationId)` inspect the current projection's phase/id and use `commandDriveOwned` for the final exact-Drive admission fence. They perform no durable control reads. The provider/tool callback enqueues synchronously, settlement seals then drains, and the settling transaction includes `clearWrite()`. - -### 2.8 Tool batch — approved shape, illustrative bodies - -The tool procedure is one ordinary async routine. It keeps its process-local started-promise map, but every status patch is computed from the current `Lane.state` supplied to that specific `commandDriveOwned` job. It never patches a batch snapshot captured before the job. - -Each child uses module-local results only: - -```ts -type ToolChildResult = - | { kind: "committed" } - | { kind: "state_changed" } - | LostOwnership; - -type ToolTransitionResult = ToolChildResult | { kind: "unchanged" }; - -type ToolStartResult = - | { kind: "started"; work: Promise } - | { kind: "finished"; result: ToolChildResult }; -``` - -These are local facts needed by this procedure; none becomes drive-loop vocabulary. `startCall` and `recoverCall` perform one call's source-ordered clearance through intent/admission. A real effect is already admitted before they return `{ kind: "started" }`; `work` owns only effect settlement and outcome staging. An immediate blocked, missing, invalid, or unsafe-recovery outcome is staged before `{ kind: "finished" }` returns. This keeps clearance/intent source-ordered while parallel effects settle independently. - -```ts -async function runToolBatch( - lane: Lane, drive: Drive, run: RunState, batch: ToolBatch, -): Promise { - // A crash can leave source-ready outcome_ready calls with no live task. Place them - // before resolving tools or starting effects. If this changes state, redispatch - // instead of continuing with the captured batch. - const prefix: ToolTransitionResult = await materializeReadyPrefix(lane, drive, batch.turnId); - if (prefix.kind === "lost_ownership") return prefix; - if (prefix.kind === "committed" || prefix.kind === "state_changed") { - return { kind: "continue" }; - } - - const sequential = run.settings.toolExecution === "sequential"; - const toolContext = await resolveToolContext(lane, drive); // exactly once for this batch pass - const started = new Map>(); - let cancellation: Promise | undefined; - - try { - for (const call of batch.calls) { - if (call.status === "completed" || call.status === "outcome_ready") continue; - - // Await only through clearance, intent commit, and synchronous effect admission. - // The loop therefore admits calls in source order. In parallel mode an admitted - // earlier effect may run while the next call is being cleared. - const launch: ToolStartResult = call.status === "effect_pending" - ? await recoverCall(lane, drive, batch, call, toolContext) // orphan by construction - : await startCall(lane, drive, batch, call, toolContext); - - let result: ToolChildResult | undefined; - if (launch.kind === "started") { - // Attach the fault observer immediately. The original promise remains in the - // map for ordinary result handling and cancellation reconciliation. - void launch.work.catch(() => {}); - started.set(call.sourceIndex, launch.work); - if (sequential) result = await launch.work; - } else { - result = launch.result; - } - - if (result?.kind === "lost_ownership") return result; - if (result?.kind === "state_changed") { - cancellation = Promise.resolve(); // the current projection already carries it - break; - } - - if (sequential) { - // Sequential means stage and place this call before clearing the next one. - const placed: ToolTransitionResult = await materializeReadyPrefix( - lane, drive, batch.turnId, - ); - if (placed.kind === "lost_ownership") return placed; - if (placed.kind === "state_changed") { - cancellation = Promise.resolve(); - break; - } - - const current = lane.state.operation; - if (current === null || current.meta.operationId !== drive.operationId || - current.state.kind !== "run" || current.state.control.status === "cancel_requested" || - current.state.phase.kind !== "tools" || - current.state.phase.batch.turnId !== batch.turnId) { - return { kind: "continue" }; - } - } - } - - if (!sequential && cancellation === undefined) { - // Every eligible call has now passed source-ordered clearance/intent admission. - // Settlement and outcome staging complete independently in completion order. - const results = await Promise.all(started.values()); - if (results.some((result) => result.kind === "lost_ownership")) { - return { kind: "lost_ownership" }; - } - if (results.some((result) => result.kind === "state_changed")) { - cancellation = Promise.resolve(); - } - } - } catch (error) { - if (!(error instanceof AbortRequested)) { - // Every started promise already has an observer; rethrow without detaching - // unobserved sibling rejections under close, fault, or finalization. - throw error; - } - cancellation = error.cancellation; - } - - if (cancellation !== undefined) { - await cancellation; - - // Some parallel children may already be running or staged. Observe every result - // before handling calls that never passed admission. - const results = await Promise.allSettled(started.values()); - let unexpected: { error: unknown } | undefined; - for (const result of results) { - if (result.status === "fulfilled") { - if (result.value.kind === "lost_ownership") return result.value; - continue; - } - if (!(result.reason instanceof AbortRequested) && unexpected === undefined) { - unexpected = { error: result.reason }; - } - } - if (unexpected !== undefined) throw unexpected.error; - - // Derive this patch from the current batch. It stages only calls that never - // started and leaves real finalized outcomes intact. - const staged: ToolTransitionResult = await stageUnstartedCalls( - lane, drive, batch.turnId, new Set(started.keys()), - ); - if (staged.kind === "lost_ownership") return staged; - if (staged.kind === "state_changed") return { kind: "continue" }; - } - - // Placement is separate from completion-order staging. It inserts only the current - // contiguous ready prefix, in source order, and may finish the batch checkpoint. - const placed: ToolTransitionResult = await materializeReadyPrefix(lane, drive, batch.turnId); - if (placed.kind === "lost_ownership") return placed; - return { kind: "continue" }; -} -``` - -Every child command rereads no control values from storage; it patches the authoritative state supplied to that lane job: - -```ts -function patchCall(state: RunState, sourceIndex: number, next: ToolCall): RunState { - if (state.phase.kind !== "tools") throw new SessionInvariantError("not a tools phase"); - return { - ...state, - phase: { kind: "tools", batch: { - ...state.phase.batch, - calls: state.phase.batch.calls.map((call) => - call.sourceIndex === sourceIndex ? next : call), - } }, - }; -} -``` - -Staging is completion-ordered (`outcome_ready`); `materializeReadyPrefix` places the contiguous ready prefix in source order in one commit, and `addedToolNames` takes effect only at placement. Under cancellation, no later call starts, every started promise is observed, unstarted calls receive their specified synthetic outcomes, and source-order materialization finishes before reconciliation continues. - -### 2.9 Terminal transactions stay in their procedures — approved - -The procedure that owns a terminal boundary also owns its terminal `Lane.commandDriveOwned`. An authorized in-process external finalizer is the one exception: it owns an ordinary `Lane.command` because it may finalize an unowned operation. Its `materialize` continuation records `finalizedOutcome` and closes any matching live Drive gate synchronously after projection publication. The finalizer settles that Drive only after `Lane.command` returns, so terminal event delivery still precedes public completion. The finalizer must still verify that the current projection names the exact operation it intends to end; a terminal winner observed first commits nothing. Each procedure already knows its exact semantic state, local result, publication writes, public outcome, and terminal event. There is no generic `commitTerminal`, `TerminalRequest`, terminal validator callback, or terminal planner. - -Inside that one command, the procedure: - -1. verifies its exact terminal boundary against the current authoritative projection; -2. computes its bounded `LaneLastResult` directly from known state and reserved ids; -3. prepends any procedure-specific publication writes (summary entry, navigation tip/label movement, usage); -4. appends the shared mechanical operation-cleanup writes; -5. writes `laneLastResult` and idle `laneState`; -6. publishes the exact idle process-local projection, including any tip movement in its publication writes; -7. constructs the complete `{ kind: "settled", outcome }` and its one terminal event from `CommitResult`, which supplies storage-assigned entry sequence and timestamp when needed. - -A normal completed-run finish is representative. Its terminal check is visibly part of the checkpoint procedure: - -```ts -return lane.commandDriveOwned(drive, async (state, reader) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("run finish reached a non-run operation"); - } - const run = operation.state; - if (run.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (run.phase.kind !== "checkpoint" || run.phase.continuation.kind !== "may_finish") { - throw new SessionInvariantError("run finish lost its checkpoint boundary"); - } - if (run.inbox.steer.length !== 0 || run.inbox.followUp.length !== 0 || run.inbox.writes.length !== 0) { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (state.tipId === null) throw new SessionInvariantError("completed run has no tip"); - - const finalId = run.phase.continuation.includeFinalAssistant - ? run.latestAssistantEntryId - : null; - const finalEntry = finalId === null - ? undefined - : (await reader.getEntries([finalId], drive.context)).get(finalId); - if (finalId !== null && - (finalEntry?.type !== "message" || finalEntry.message.role !== "assistant" || - finalEntry.message.stopReason === "pending")) { - throw new SessionInvariantError(`Run finish references invalid assistant ${finalId}`); - } - const final = finalEntry?.type === "message" && finalEntry.message.role === "assistant" - ? { finalEntryId: finalEntry.id, finalMessage: finalEntry.message } - : {}; - const outcome: TerminalOperationOutcome = { - operation: "run", runId: drive.operationId, kind: "completed", - tipId: state.tipId, ...final, - }; - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "run", - outcome: "completed", - tipId: state.tipId, - runCompletion: finalId === null ? "terminated_tools" : "assistant", - ...(finalId === null ? {} : { finalAssistantEntryId: finalId }), - }; - const cleanup = await operationCleanupWrites( - reader, drive.operationId, run, drive.context, - ); - return { - kind: "commit", - writes: [ - ...cleanup, - setValue(laneLastResult(lane.name), lastResult), - setValue(laneStateValue(lane.name), { - currentOperationId: null, - pendingNextRun: state.pendingNextRun, - }), - ], - next: { ...state, lastResult, operation: null }, - materialize: () => ({ kind: "settled", outcome } as const), - events: () => [{ - type: "run_end", lane: lane.name, runId: drive.operationId, - tipId: state.tipId, outcome: "completed", ...final, - }], - }; -}, drive.context); -``` - -Standalone structural and navigation procedures use the same visible suffix after their own result-publication writes. They do not call a generic terminal procedure. - -`runtime/drive/terminal.ts` contains only mechanical helpers: - -- `operationCleanupWrites(reader, operationId, operationState, context)` scans typed operation prefixes and returns concrete deletes for meta/state, tool arguments, memos, preparations, tool-output checkpoints, the exact live assistant-frame list, and operation-owned pending entries; -- `hydrateTerminalOutcome(reader, lastResult, context)` dereferences only entries directly named by the bounded value. - -Each procedure constructs its own `LaneLastResult` visibly. Entry-producing terminal procedures construct public entries and events in `materialize(commit)` / `events(commit)`, after storage assigned sequence and timestamp. Operation-id matching remains visible at the drive claim/hydration call site rather than hiding in another helper. - -`pendingNextRun` is lane-owned and survives. Defensive deletes of already-removed addresses are no-ops. **Never** add `delete_prefix`. The outer drive loop performs process-local completion once after receiving `settled`; it does not construct or commit the terminal transaction. - -### 2.10 GatingStorage + representative test — approved - -Three properties make this a crash simulator rather than a delay: `discard()` is **permanent and total** (a commit arriving afterwards is rejected, never forwarded), `next()` **waits** for a commit to be parked instead of racing it, and `next()` resolves only once the released commit has actually **landed** in the backend. The old `setTimeout(0)` version failed all three: a late-settling effect could commit after the simulated crash because `discarded` fell through to `super.commit`, and a test could call `next()` before the drive had parked anything. - -```ts -// packages/agent/src/harness/session/testing/gating-storage.ts - -/** Thrown for any commit attempted after discard(). Never reaches the backend. */ -export class CommitDiscarded extends Error { - constructor(message: string) { - super(message); - this.name = "CommitDiscarded"; - } -} - -interface ParkedCommit { - release(): void; - drop(error: Error): void; - /** Resolves once the released commit landed; rejects if it was dropped or failed. */ - readonly landing: Promise; -} - -export class GatingStorage extends StorageDecorator { - private armed = false; - private discarded = false; - private readonly queue: ParkedCommit[] = []; - private readonly waiters: Array<{ count: number; resolve: () => void }> = []; - - /** Setup bypass: fixture writes commit ungated until arm() is called. */ - arm(): void { this.armed = true; } - pending(): number { return this.queue.length; } - - /** Resolves once at least `count` commits are parked. Removes the next()/drive race. */ - waitPending(count = 1): Promise { - if (this.queue.length >= count) return Promise.resolve(); - return new Promise((resolve) => { this.waiters.push({ count, resolve }); }); - } - - override async commit(writes: Write[], context: Context): Promise { - // After discard() this storage is dead. A late effect settling afterwards must never - // reach the backend, so there is no bypass here — not even for unarmed commits. - if (this.discarded) throw new CommitDiscarded("commit rejected: storage discarded"); - if (!this.armed) return super.commit(writes, context); - - let landed!: () => void; - let lost!: (error: Error) => void; - const landing = new Promise((resolve, reject) => { landed = resolve; lost = reject; }); - void landing.catch(() => {}); // observed by next(); keep Node quiet meanwhile - - const released = new Promise((resolve, reject) => { - this.queue.push({ release: resolve, drop: reject, landing }); - }); - this.notifyWaiters(); - - try { - await released; - if (this.discarded) throw new CommitDiscarded("commit rejected: storage discarded"); - const result = await super.commit(writes, context); - landed(); - return result; - } catch (error) { - lost(error instanceof Error ? error : new Error(String(error))); - throw error; - } - } - - /** Release `count` parked commits, waiting for each to be parked and to land. */ - async next(count = 1): Promise { - for (let i = 0; i < count; i++) { - await this.waitPending(1); - const parked = this.queue.shift(); - if (parked === undefined) throw new Error("no parked commit"); - parked.release(); - await parked.landing; // resolves only after the write is in the backend - } - } - - /** Crash simulation: parked commits are dropped and no future commit may land. */ - discard(): void { - this.discarded = true; - for (const parked of this.queue.splice(0)) { - parked.drop(new CommitDiscarded("commit discarded")); - } - this.notifyWaiters(); - } - - private notifyWaiters(): void { - for (let i = this.waiters.length - 1; i >= 0; i--) { - if (this.queue.length >= this.waiters[i].count) this.waiters.splice(i, 1)[0].resolve(); - } - } -} -``` - -Composition (approved): `new InstrumentedStorage(new GatingStorage(new MemoryStorage({ now })))` — attempts are recorded at attempt time, gating parks between recording and storage. - -```ts -// representative deterministic test (illustrative body, approved structure) -const storage = new InstrumentedStorage(new GatingStorage(new MemoryStorage({ now: () => NOW }))); -const { harness, gating, provider } = await createDriveFixture(storage); // setup bypass active -const admission = unwrap(await harness.accept({ kind: "prompt", prompt: "q" }, ctx)); -gating.arm(); - -const driving = harness.drive({ operationId: admission.operationId }, ctx); -await gating.next(); // starting -> checkpoint (waits to be parked) -await gating.next(); // checkpoint -> assistant ready -await gating.next(); // intent: effect_pending -expect(await phaseOf(session, admission.operationId)).toMatchObject({ kind: "assistant" }); - -provider.resolve(fauxAssistantMessage("answer")); // effect window released -await gating.next(); // settlement -gating.discard(); // crash before terminal -await expect(driving).rejects.toBeDefined(); - -// A late effect settling after the crash must not reach the backend. -await expect(storage.commit([/* any write */], ctx)).rejects.toBeInstanceOf(CommitDiscarded); - -await harness.close(ctx); -const reopened = await repo.open(session.metadata, ctx); -// the discarded commit never landed; drive resumes from the settled checkpoint -``` - -Determinism: `MemoryStorage` accepts `now`; `Session.idGenerator` must become injectable (M1 API change) or tests compare under first-appearance id normalization. - -### 2.11 Direct Lane procedure access and generic discipline — approved - -Drive procedures are package-internal and receive the concrete `Lane` plus `Drive`. There is no capability interface or dependency bundle. - -```ts -// every drive procedure -import type { Lane } from "../lane.ts"; -import type { Drive, ProcedureResult } from "../types.ts"; - -async function runCheckpoint( - lane: Lane, drive: Drive, run: RunState, - phase: Extract, -): Promise { /* ... */ } -``` - -`Lane` already exposes the package-internal members procedures need: `state`, `session`, `models`, `hooks`, `command`, `emitBatch`, `readConfig`, `mismatch`, and `isDriveActive`; M2 adds only `commandDriveOwned` for exact-Drive commit admission. The package root exports only `AgentLane` / `AgentHarness`, so these members do not widen public API. Procedure imports of `Lane` are type-only and erase; `lane.ts` may value-import `drive.ts` in M8 without a runtime cycle. - -Rules: -- `TContext extends object | undefined` everywhere; no other bound. -- No `any`, `Lane`, `as unknown as`, or `@ts-expect-error`. -- `Lane` remains invariant because config consumes and produces `TContext`; never widen it. -- Tests use a concrete `Lane` or `Lane<{ cwd: string }>` and install a `Drive` directly. -- A procedure never calls public reentrant lane mutations from inside a `Lane.command` planner. The command contract remains the single write path. -- Tool update options retain the exact `checkpoint?: true` literal shape. - -### 2.12 Usage totals from the commit boundary — approved - -A usage event must carry the session totals **as of its own commit**, and `Lane.command` already materializes and emits synchronously from `CommitResult`: - -```ts -materialize(commit: CommitResult): Synchronous; -events?(commit: CommitResult): HarnessEvent[]; -``` - -So the totals must be on `CommitResult`. A process-local accumulator would have to be seeded on attach and ordered across lanes by commit sequence, and would be wrong after any write that did not go through it (reopen, fork, external writer). The backends already maintain exactly the needed number incrementally. - -**Target: `CommitResult` carries the post-commit `SessionStats` snapshot.** - -```ts -// packages/agent/src/harness/session/types.ts -export interface CommitResult { - firstSeq: number; - seqs: number[]; - timestamp: number; - /** Session totals immediately after this commit was applied. */ - stats: SessionStats; -} -``` - -For the deliberately materialized Memory and JSONL backends, `InMemoryStorageState` already folds `messageCount` and `usage` in `applyValidated` and is rebuilt from the log on JSONL open. It is not an architecture for database backends or long-running sessions that may not fit in memory. SQLite maintains and reads indexed durable aggregates inside its commit transaction. - -| File | Change | -|---|---| -| `src/harness/session/types.ts` | add `stats: SessionStats` to `CommitResult` | -| `src/harness/session/commit.ts` | `PreparedCommit.result` becomes `Omit`; `prepareStorageCommit` is unchanged otherwise (it runs before apply and cannot know the totals) | -| `src/harness/session/in-memory-storage-state.ts` | `InMemoryStorageState.applyValidated(writes)` returns the post-apply `SessionStats` for the deliberately materialized Memory and JSONL backends | -| `src/harness/session/memory.ts` | `const stats = this.storageState.applyValidated(prepared.writes); return { ...prepared.result, stats };` | -| `src/harness/session/jsonl/storage.ts` | same composition inside `applyCommit` | -| `packages/session-backends/sqlite-node/src/sqlite/storage.ts` | **third backend — do not miss it.** `applyCommit` already runs inside `this.db.transaction(…)` and already maintains stats there via `incrementMessageCount` / `addUsageToSessionStats`. Read them back with `readSessionStats(this.db)` **inside the same transaction closure**, after `advanceNextSeq`, so the snapshot is atomic with the writes that produced it (see below) | -| `src/harness/session/testing/{storage-decorator,instrumented-storage}.ts` | no change: both forward the backend's `CommitResult` unmodified. Verify, do not rewrite | -| `src/harness/session/testing/conformance/storage.ts` | assert `(await storage.commit(…)).stats` deep-equals `await storage.getStats(…)` after every commit, and that totals after reopen include history. All three backends run this suite | -| `packages/session-backends/sqlite-node/test/storage-conformance.test.ts`, `packages/session-backends/sqlite-node/test/storage.test.ts` | the shared suite covers SQLite through `"SqliteStorage conformance"`; add a backend-local assertion that the returned `stats` is the post-write snapshot, not a pre-write one. Every path in this row is outside `packages/agent`, unlike every other bare `test/…` path in this handoff | - -The SQLite composition, in full — the placement is the whole point: - -```ts -// packages/session-backends/sqlite-node/src/sqlite/storage.ts -private applyCommit(writes: Write[]): CommitResult { - this.beforeCommit(); - return this.db.transaction(() => { - const firstSeq = readNextSeq(this.db); - const prepared = prepareStorageCommit(writes, firstSeq, this.now()); - for (const write of prepared.writes) { - // … unchanged: entry / usage / value / list, including incrementMessageCount - // and addUsageToSessionStats, which already write the stats row here. - } - advanceNextSeq(this.db, firstSeq + prepared.writes.length); - // Read AFTER the loop and INSIDE the transaction: it must observe this commit's own - // increments, and it must be atomic with them. Reading outside would race the next - // queued commit and could report totals that include writes this caller never made. - return { ...prepared.result, stats: readSessionStats(this.db) }; - }); -} -``` - -The event shape is fixed by `agent-harness.ts` and must be emitted exactly: - -```ts -| { type: "usage"; lane: string; row: UsageRow; totals: Usage } -``` - -There is no `runId` field, and `row` is a complete `UsageRow` **including its committed `seq`**. But `insertUsage` takes `Omit` — storage assigns the seq. `prepareStorageCommit` assigns `seqs[i] = firstSeq + i` for `writes[i]`, so a write's committed seq is `commit.seqs[]`. Name that index; do not recompute it from `firstSeq`: - -```ts -const usageRow: Omit = { id: usageId, usage: response.usage, entryId: responseEntryId, adjustment: false }; - -const writes: Write[] = [ - insertEntry(responseEntry), // 0 - insertUsage(usageRow), // 1 - setValue(branchTip(lane.name), responseEntryId), // 2 - deleteList(pendingAssistantFrames(drive.operationId, responseEntryId)), - setValue(operationStateValue(drive.operationId), nextState), -]; -const ENTRY_WRITE = 0; -const USAGE_WRITE = 1; - -return { - kind: "commit", writes, - next: { ...state, operation: { meta: operation.meta, state: nextState } }, - materialize: (): ProcedureResult => ({ kind: "continue" }), - events: (commit) => [ - { type: "entry_added", lane: lane.name, - entry: { ...responseEntry, seq: commit.seqs[ENTRY_WRITE], timestamp: commit.timestamp } }, - { type: "usage", lane: lane.name, - row: { ...usageRow, seq: commit.seqs[USAGE_WRITE] }, - totals: commit.stats.usage }, // exact totals as of THIS commit, synchronously - ], -}; -``` - -Cross-lane ordering is first the sole Session mutation line and then the storage commit queue: two lanes committing usage observe two different snapshots in commit order, and the later one includes the earlier. A consumer keeping the greatest `row.seq` it applied therefore never regresses totals (harness.md §5.5). - -**Fabricated `CommitResult` literals must be inventoried**, because adding a required field breaks every hand-written one. Current sites: - -| Site | Literal | -|---|---| -| `test/harness/types.test.ts:375`, `:388` | structural type `Promise<{ firstSeq: number; seqs: number[]; timestamp: number }>` — add `stats: SessionStats` | -| `test/harness/instrumented-storage.test.ts:58`, `:62`, `:74` | `{ firstSeq: 1, seqs: [1], timestamp: 10 }` — add `stats` | - -Audit for any others with `grep -rn "firstSeq" packages/agent/src packages/agent/test` and fix every match before `npm run check`. - ---- - -## 3. Milestones - -Every milestone uses the same subsections. Public drive stays disabled until M8. - -### Guard that keeps public drive disabled (M1–M7) - -`runtime/lane.ts` keeps throwing `SliceNotImplemented` from `drive`, `requestAbort`, `prompt`, `skill`, `promptFromTemplate`, `compact`, `navigateTree`, `resume`, `abort`, `steer`, `followUp`, `nextRun`, `cancelQueued`, `recordUsage`, `waitForIdle`, `runWhenIdle`, and from `accept({ kind: "compaction" })` / `accept({ kind: "navigation" })`. `requestAbort` stays guarded through M7 even though its primitive lands there: a durable `cancel_requested` marker on an operation that nothing can drive has no exit. Procedures are exercised with a concrete `Lane` and `Drive` constructed in each focused test against directly constructed durable state. **M8 removes those throws in one commit**, after every phase and reconciliation path exists. - ---- - -### M0 — Remove breakpoints and manual drive (independently committable) - -**Goal.** No breakpoint or manual-drive concept anywhere. No behavior added. - -**Read completely before editing** -- `packages/agent/src/harness/execution/breakpoint.ts` -- `packages/agent/src/harness/agent-harness.ts` -- `packages/agent/src/harness/runtime/{lane,types,harness}.ts` -- `packages/protocol/src/harness.ts`, `packages/protocol/src/codec.ts` -- `packages/coding-agent/src/experimental/harness-wire-adapter.ts` -- `packages/agent/test/harness/execution-primitives.test.ts`, `test/harness/types.test.ts` -- `packages/protocol/test/protocol.test.ts`, `packages/coding-agent/test/experimental-harness-wire-adapter.test.ts` -- `packages/agent/docs/harness.md` §4.1, §4.2, §4.5, §4.6, §4.7, §5.1, §5.2, §5.4, §9.1, §9.3, glossary - -**Delete** -- `packages/agent/src/harness/execution/breakpoint.ts` - -**Create** — none. - -**Modify** -| Path | Symbols/sections | -|---|---| -| `src/harness/agent-harness.ts` | `ActionInfo`, `ActionRequired`; `ActionRequired` member of `RunOutcome`/`CompactionOutcome`/`NavigationOutcome`; `action_required` member of `DriveOutcome`; `SettledResumeOutcome`; `Exclude<…, ActionRequired>` in `TerminalOperationOutcome`; `LaneSnapshot.operation.action`; `AgentLane.peekAction`/`executeAction`/`runToCompletion`; `AgentHarnessOptions.drive` | -| `src/harness/runtime/types.ts` | `Config.drive` | -| `src/harness/runtime/harness.ts` | `drive: options.drive ?? "automatic"` | -| `src/harness/runtime/lane.ts` | the three action-method stubs | -| `packages/protocol/src/harness.ts` | `ActionInfoSchema`; `action_required` member of `RunValueSchema`; optional `action` on the operation snapshot schema | -| `packages/coding-agent/src/experimental/harness-wire-adapter.ts` | `case "action_required":` | -| `packages/agent/docs/harness.md` | sections listed above | -| `packages/agent/docs/work-packages/{02-atomic-run-acceptance,03-remove-drive-deadlines,04-mutation-publication}.md` | forward-looking pointers only | -| `packages/agent/CHANGELOG.md`, `packages/protocol/CHANGELOG.md`, `packages/coding-agent/CHANGELOG.md` | Breaking/Removed entries | - -**Behavior/invariants landed.** `AgentLane` has no manual-action surface; `DriveOutcome` is `settled | waiting`; no wire schema carries `ActionInfo`. - -**Focused tests to create/modify** -| Path | Theme | -|---|---| -| `test/harness/execution-primitives.test.ts` | delete `describe("Breakpoint")` + import; keep gate/hooks/events blocks | -| `test/harness/types.test.ts` | `DriveOutcome["kind"]` → `"settled" \| "waiting"` | -| `packages/protocol/test/protocol.test.ts` | drop the `action_required` fixture; add a decode-rejection case | -| `packages/coding-agent/test/experimental-harness-wire-adapter.test.ts` | drop the `action_required` fixture | - -**Commands** -```bash -npm run check -./test.sh -grep -rn "Breakpoint\|action_required\|ActionInfo\|ActionRequired\|peekAction\|executeAction\|runToCompletion\|SettledResumeOutcome" \ - packages/*/src packages/*/test packages/agent/docs/harness.md -``` -Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `highlight.min.js` may match. - -**Do not implement yet.** No `Drive`, no `Gate`, no drive procedures. - -**Exit condition / review questions.** Grep clean; `npm run check` and `./test.sh` pass; changelogs updated. Did any removed sentence carry a durability requirement that must be kept in reworded form? - -**Protocol version decision.** `PROTOCOL_VERSION` is **not** bumped: `action_required` was reachable only through manual drive, which never shipped enabled, so no producer emitted it. Record the decision in `packages/protocol/CHANGELOG.md`. - ---- - -### M1 — Foundations (review checkpoint) - -**Goal.** Ownership record, gate, progress, generic parameters, commit-boundary totals, deterministic test harness. Nothing drives. - -**Read completely before editing** -- `src/harness/execution/effect-gate.ts`, `src/harness/hooks.ts` -- `src/harness/runtime/{lane,types,harness,restore}.ts` -- `src/harness/session/{values,types,session,commit,in-memory-storage-state,memory}.ts` -- `src/harness/session/jsonl/storage.ts` -- `src/harness/session/testing/{storage-decorator,instrumented-storage,index}.ts` -- `src/harness/session/testing/conformance/storage.ts` -- `packages/agent/src/index.ts` (confirm it exports no `runtime` module, §2.11) -- `test/harness/runtime/test-utils.ts`, `test/harness/instrumented-storage.test.ts`, `test/harness/types.test.ts` - -**Delete** — none. - -**Create** -- `src/harness/session/testing/gating-storage.ts` — `GatingStorage` (§2.10) -- `src/harness/runtime/progress.ts` — `ProgressChannel`, `openFrameProgress`, `openToolProgress` (§2.7) -- `test/harness/gating-storage.test.ts` - -**Modify** -| Path | Change | -|---|---| -| `src/harness/execution/effect-gate.ts` | add `Gate`, `GateControl`, `createGate()`, `DriveAbandoned` (§2.2); keep `AbortRequested`; retire `EffectGate.assertOpen` from the procedure-facing surface | -| `src/harness/hooks.ts` | `runWithGate`/`runToolWithGate` accept `Gate` and use `admit` | -| `src/harness/result.ts`, `src/harness/agent-harness.ts` | keep tagged/runtime errors in the dependency-neutral result module and re-export them from `agent-harness.ts`; this lets `agent-harness.ts` directly wire `createAgentHarness` while concrete Lane imports remain order-independent | -| `src/harness/runtime/types.ts` | add the `Drive` class (completion, gate control, stripped pass Context, installer signal, wait policy, mutable `deferredPermits`, mutable `finalizedOutcome`) and the initial procedure-result union, simplified in M2; move `LaneCommand` here from `lane.ts` | -| `src/harness/runtime/lane.ts` | make the class `Lane`; expose package-internal `session`, `models`, `hooks`, `emitBatch`, `command`, `readConfig`, and `mismatch` for direct procedure use; add package-internal `activeDrive: Drive | undefined` and `isDriveActive(drive)`; import `LaneCommand` from `types.ts`; remove the obsolete `AcceptanceConfig` type | -| `src/harness/runtime/harness.ts` | `Harness` manages ordinary `Lane` objects by composition; `buildLane` returns `Lane`; pass `HookRegistry` and full `Config` into each Lane | -| `src/harness/runtime/restore.ts` | name `Lane` wherever it names `Lane` | -| `src/harness/session/types.ts` | add `stats: SessionStats` to `CommitResult` (§2.12) | -| `src/harness/session/commit.ts` | `PreparedCommit.result` becomes `Omit` | -| `src/harness/session/in-memory-storage-state.ts` | `InMemoryStorageState.applyValidated` returns the post-apply `SessionStats` for Memory and JSONL | -| `src/harness/session/memory.ts`, `src/harness/session/jsonl/storage.ts` | compose `{ ...prepared.result, stats }` | -| `packages/session-backends/sqlite-node/src/sqlite/storage.ts` | return `{ ...prepared.result, stats: readSessionStats(this.db) }` from **inside** the `db.transaction` closure (§2.12) | -| `packages/session-backends/sqlite-node/test/storage-conformance.test.ts`, `packages/session-backends/sqlite-node/test/storage.test.ts` | SQLite conformance plus a post-write-snapshot assertion | -| `src/harness/session/testing/conformance/storage.ts` | assert commit-result totals against `getStats`, including after reopen | -| `test/harness/types.test.ts` | the two structural `CommitResult` literals at `:375` and `:388` gain `stats: SessionStats` (§2.12) | -| `test/harness/instrumented-storage.test.ts` | the three fabricated literals at `:58`, `:62`, `:74` gain `stats` (§2.12) | -| `src/harness/session/session.ts` | make `idGenerator` injectable through constructor options (**named API change**; alternative: canonical id normalization in tests) | -| `src/harness/session/testing/index.ts` | export `GatingStorage`, `CommitDiscarded` | -| `test/harness/runtime/test-utils.ts` | construct concrete `Lane` and `Drive` objects directly; name concrete contexts wherever a Lane is constructed | - -**Behavior/invariants landed.** `admit` is the only gate check; `GateControl` is owner-held; progress `drain()` propagates write failures; `GatingStorage` parks, waits, releases, and discards deterministically, and after `discard()` no commit reaches the backend by any path; `CommitResult.stats` reports exact post-commit session totals seeded from storage state; `Lane`/`Harness` remain generic in `TContext`; `Drive` is context-type independent; no `any`. - -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/gating-storage.test.ts` | setup bypass; `waitPending()` resolves for a commit parked after the call; FIFO `next()` resolves only after the released commit lands; `pending()`; `discard()` rejects parked commits **and** every later commit with `CommitDiscarded`, including an unarmed one; composition order with `InstrumentedStorage` | -| `test/harness/execution-primitives.test.ts` | `createGate` admit/abort/close; `DriveAbandoned` distinct from `AbortRequested` | -| `test/harness/types.test.ts` | `CommitResult` carries `stats`; the published surface is unchanged — `AgentLane`/`AgentHarness` gain no member from `Lane` | -| `src/harness/session/testing/conformance/storage.ts` | `commit(…).stats` equals `getStats()` after each commit; totals after reopen include historical usage | - -**Commands.** `npm run check` · `./test.sh` - -**Do not implement yet.** No phase procedures, no `drive.ts`, no `runDrive`, no public wiring. - -**Exit condition / review questions.** Gate has no `assertOpen` reachable from procedures. Can any commit reach the backend after `discard()`? Does `next()` ever resolve before the write landed? Is there a single `any` or variance widening introduced by the generic change? Is `idGenerator` injection or normalization decided and recorded? - ---- - -### M2 — Terminal mechanics and bounded hydration - -**Goal.** Land only the mechanical pieces used by terminal commands. No procedure finishes an operation in M2; each later phase-owning procedure writes its terminal transaction inline when that phase lands. - -**Read completely before editing** -- `src/harness/session/types.ts` (`OperationState`, `LaneLastResult`; `TerminalOperationOutcome` in `agent-harness.ts`) -- `src/harness/session/values.ts` (all operation cleanup prefix constructors) -- `src/harness/runtime/{lane,types,restore,progress}.ts` -- `test/harness/runtime/{harness,watch,restore}.test.ts` - -**Create** -- `src/harness/runtime/drive/terminal.ts` — `operationCleanupWrites`, `hydrateTerminalOutcome` (§2.9) -- `test/harness/runtime/drive-terminal.test.ts` - -**Modify** -| Path | Change | -|---|---| -| `src/harness/runtime/types.ts` | replaced global `StepResult.advance/reload` with `ProcedureResult.continue`; retained waiting, settled, and lost ownership (§2.4) | -| `src/harness/runtime/lane.ts` | add `commandDriveOwned`, with exact-Drive identity checked immediately before commit admission (§2.5); document `state` as the live control authority | -| `src/harness/runtime/progress.ts` | use the planner's current `LaneState` and `commandDriveOwned`; remove durable lane/operation control reads (§2.7) | -| `src/harness/session/session.ts` | document retained mutators as forbidden while a harness owns the Session; no runtime compatibility layer | - -**Behavior/invariants landed.** A live `Lane.state` is the sole control-flow authority. Process loss restores it from durable values. Runtime reads dereference payloads only. Exact-Drive replacement between planner return and commit admission declines the commit. Cleanup enumerates every operation-owned address through typed prefix scans and concrete deletes; `pendingNextRun` is not cleanup-owned. Hydration reads only the bounded latest-result value and entries it directly names. - -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/runtime/lane.test.ts` | exact owner replaced after an async planner returns but before commit admission → no write; commit admitted first → write and projection publish complete before replacement | -| `test/harness/runtime/progress.test.ts` | current projection permits matching frame/tool progress; terminal or phase movement first → queued write declines; no durable control reads | -| `test/harness/runtime/drive-terminal.test.ts` | cleanup completeness for run, compaction, navigation, including leftovers and exact live frame list; `pendingNextRun` excluded; bounded hydration equality for directly constructed latest-result values per kind | -| `test/harness/runtime/restore.test.ts` | required durable projection absence/contradiction remains attachment corruption; a stored null tip remains valid | - -**Commands.** `npm run check` · targeted vitest for every modified test file - -**Do not implement yet.** No terminal commit procedure, synthetic recovery, switch, generation, or public wiring. - -**Exit condition / review questions.** Does any supported control write bypass the owning Lane? Is exact Drive identity adjacent to commit admission? Does terminal.ts contain anything beyond mechanical cleanup and bounded hydration? - -### M3 — Generation (review checkpoint) - -**Goal.** `starting → checkpoint → assistant ready → intent → stream → settlement`, frames, configuration failure. - -**Read completely before editing** -- `src/harness/execution/assistant.ts` (full), `src/harness/hooks.ts` -- `src/harness/session/context.ts`, `src/harness/messages.ts`, `src/harness/system-prompt.ts` -- `src/harness/compaction/compaction.ts` (settings only) -- `test/harness/execution-assistant.test.ts` - -**Create** -- `src/harness/runtime/drive/checkpoint.ts` — `startRun`, `runCheckpoint` -- `src/harness/runtime/drive/generation.ts` — `runGeneration`, `settleAssistant`, `enterConfigurationFailure` (§2.6) -- `src/harness/runtime/drive/recovery.ts` — synthetic interrupted-assistant settlement from committed frames, owned beside generation classification -- `test/harness/runtime/drive-generation.test.ts` - -**Do not create `drive.ts` here.** A switch written now would have to import `drive/deferred.ts` (M4), `drive/tools.ts` (M5), `drive/structural.ts` (M6), and `drive/reconcile.ts` (M7), none of which exist — the package would not compile, and stubbing those modules to make it compile would create exactly the half-built graph this plan forbids. `Drive` lives in `runtime/types.ts` from M1 and `ProcedureResult` from M2, so procedures compile and are tested standalone. `drive.ts` is created once, in M7. - -**Modify** -| Path | Change | -|---|---| -| `src/harness/execution/assistant.ts` | use provider-facing `Tool` declarations; observer callbacks receive trailing Context, and `start` receives the actual start event; pre-generation errors synthesize no start, while updates or successful completion before start are protocol defects; the runtime request adapter admits `Models.streamSimple` through `Gate` | - -**Behavior/invariants landed.** Preparation precedes intent; ids reserved at intent and honoured at settlement; frames enqueued synchronously without per-frame awaits; settlement deletes the frame list; every transition plans from the current owned projection and publishes its exact next projection; checkpoint separates tree parent/tip from `triggerEntryId` for unprojected custom writes. - -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/runtime/drive-generation.test.ts` | each procedure invoked directly with a concrete `Lane` and `Drive`, no switch; intent before provider; reserved-id settlement; frame order without storage awaits; orphan recovery without provider call; mixed projecting/unprojected drains (tip vs trigger); queued inbox mutation before intent → intent commits while preserving it; cancellation before intent → procedure returns `continue` and commits nothing; fence taken by another operation → returns `lost_ownership` and commits nothing; published projection equals fresh restore | - -**Commands.** `npm run check` · targeted vitest - -**Do not implement yet.** `drive.ts`, retry, deferred, tools, structural, reconciliation, public wiring. - -**Exit condition / review questions.** Does any planner throw for an expected verification failure? Does any procedure start an effect on a path whose intent did not commit? Does any procedure derive state from a pre-job snapshot? Does `packages/agent/src/harness/runtime/` contain any module importing a file that does not exist yet? - ---- +- `run.starting → run.checkpoint`; +- assistant ready/intent/effect/settlement; +- frame persistence and cleanup; +- configuration failure; +- unknown-outcome assistant recovery; +- response/usage/state atomicity. ### M4 — Retry and deferred -**Goal.** Durable waiting. +Complete. Includes durable retry waits, local wait policy, one deferred poll permit per pass, unknown-poll replacement under fresh ids, and event-stream-preserving `Models.streamDeferred`. -**Read completely before editing** -- `src/harness/session/types.ts` (`Generation.retry_wait`, `Deferred`) -- `src/harness/config.ts` (`validateRetryPolicy`, normalization) -- `src/harness/agent-harness.ts` (`DriveOptions.waitForRetry`, `pollDeferred`, `DriveOutcome.waiting`) +### M5 — Durable tools -**Create** -- `src/harness/runtime/drive/deferred.ts` -- `test/harness/runtime/drive-retry-deferred.test.ts` +Complete. Includes planned/effect-pending/outcome-ready/completed calls, safe replay, unsafe interruption, invocation memos, bounded checkpoints, completion-order staging, source-order placement, sequential and parallel modes, and tool-reported usage. -**Modify** -| Path | Change | -|---|---| -| `src/harness/runtime/drive/generation.ts` | retry classification → `retry_wait`; `waitForRetry` policy | -| `src/harness/runtime/drive/deferred.ts` | `drive.deferredPermits` consumption lives with the deferred procedure. **Do not touch `drive.ts`** — it does not exist until M7 | -| `packages/ai/src/models.ts` | add the event-stream-preserving `Models.streamDeferred`; retain `fetchDeferred` as `streamDeferred(...).result()` | -| `packages/coding-agent/src/core/model-runtime.ts` | implement the same `Models.streamDeferred` auth-preserving runtime path | +### Pre-M6 simplification -The two `Models` rows are a necessary implementation prerequisite discovered in M4: the existing promise-returning `fetchDeferred()` discarded provider events, making deferred frame persistence and partial recovery impossible. The additive stream method preserves Models auth/request transforms and avoids bypassing the registry. +Required before further graph work: -**Behavior/invariants landed.** No durable armed poll state: `suspended` never becomes a durable pollable `ready`; the permit is consumed by the **fresh intent commit** that reserves new ids; a crash before that commit leaves `suspended` at the same poll number; an unknown-outcome poll does not consume a poll, deletes the old frame list, and mints fresh ids next poll; an unknown-outcome structural attempt *does* consume an attempt. +- canonical flat `at` operation types; +- shared assistant/deferred response settlement; +- Lane transition operations; +- removal of installer abandonment, `LostOwnership`, exact-object fencing, `DriveAbandoned`, `installerSignal`, and `finalizedOutcome`; +- conversion of existing M3–M5 procedures; +- update of normative documentation and focused tests; +- measured material reduction in runtime LOC. -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/runtime/drive-retry-deferred.test.ts` | waiting outcomes; permit consumption point; crash before intent stays suspended; unknown-outcome poll number and id freshness; pass-local permit never mutates caller options | +Stop and revisit the design if this conversion does not materially reduce the existing runtime. -**Commands.** `npm run check` · targeted vitest +## 5. M6 — Structural execution and navigation -**Do not implement yet.** Tools, structural, reconciliation, public wiring. +### Goal -**Exit condition / review questions.** Can any durable state poll without a permit? +Implement every structural leaf: ---- +- in-run threshold/overflow compaction; +- standalone compaction; +- summarized and unsummarized navigation; +- structural decision hooks; +- generated summary attempts, retries, and recovery; +- atomic result publication and terminal completion. -### M5 — Tools (review checkpoint) +Create: -**Goal.** The complete tool batch. - -**Read completely before editing** -- `src/harness/execution/tools.ts` (full), `src/harness/types.ts` -- `src/harness/tools/{bash,tool-context,index}.ts` -- `src/types.ts` (`AgentToolResult`, `AgentToolUpdateCallback`, `addedToolNames`) -- `test/harness/execution-tools.test.ts`, `test/harness/tools.test.ts` - -**Create** -- `src/harness/runtime/drive/tools.ts` (§2.8) -- `test/harness/runtime/drive-tools.test.ts` - -**Modify** -| Path | Change | -|---|---| -| `src/harness/types.ts` | add `AgentHarnessToolUpdateOptions { checkpoint?: true }` — the literal `true`, not `boolean`, because `false` and absent mean the same thing — and `AgentHarnessToolUpdateCallback` (§2.11); add memo accessors to `AgentHarnessToolInvocation` (**named API change**: these are referenced by the spec but absent today) | -| `src/harness/execution/tools.ts` | admit `execute` through `Gate`; thread invocation + update options | -| `src/harness/tools/bash.ts` | keep `BASH_UPDATE_THROTTLE_MS = 100`; add a distinct checkpoint cadence of at most one per 2 s, only when the bounded snapshot changed | - -**Behavior/invariants landed.** Clearance order lookup → `prepareArguments` (ungated, throw normalizes to a synthetic error) → `before_tool` → argument intent commit; missing tools stage detail-free synthetic results; `toolContext` resolved once per batch; memos under `operationToolMemo`, deleted by staging; per-invocation update + checkpoint drain before `after_tool`; local started map; current-state patching; completion-order staging with source-order materialization; `addedToolNames` effective only at placement; abort after admission unwinds the batch. - -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/runtime/drive-tools.test.ts` | sequential and parallel two-call batches preserving sibling status; out-of-order completion → source-order placement; safe replay re-executes, unsafe does not; `outcome_ready` never re-executes; memo lifetime; checkpoint cadence; drain before `after_tool`; `addedToolNames` at placement; abort after admission | - -**Commands.** `npm run check` · targeted vitest - -**Do not implement yet.** Structural, reconciliation, public wiring. - -**Exit condition / review questions.** Does any status patch read a batch captured before its job? - ---- - -### M6 — Structural and navigation - -**Goal.** Compaction (threshold/overflow/manual) and navigation. - -**Read completely before editing** -- `src/harness/compaction/{compaction,branch-summarization,utils}.ts` -- `src/harness/session/types.ts` (`StructuralDecision`, `SummaryGeneration`, `SummaryContext`, `NavigationState`) -- `test/harness/{compaction,branch-summarization}.test.ts` - -**Ownership split (approved).** `runtime/drive/structural.ts` owns **all** persistence: the preparation row, the decision, request intent rows, usage writes, the result entry, and the terminal transaction. The compaction modules stay pure generators of summary text and keep their existing behavior for their existing callers; they gain one seam so the runtime can wrap each provider call with commit intent → `gate.admit` → commit usage. - -Today `compact()`, `generateSummaryWithUsage()`, and the private `generateTurnPrefixSummary()` each call `completeSimpleWithRetries(models, model, …)` directly, and `generateBranchSummary()` does the same through `GenerateBranchSummaryOptions`. - -**The seam must be one provider request, not one retrying call.** `completeSimpleWithRetries` wraps `models.completeSimple` in `retryAssistantCall`, so wrapping *it* would put a whole retry loop inside a single durable intent: several billed requests would settle against one reserved `usageId`, and a crash mid-loop would leave an attempt whose cost the ledger cannot attribute. harness.md §3.9 is explicit that each nested request commits its own `request:{index,usageId}` and then atomically writes usage and clears the field. - -So the runtime disables the helper's internal retry and owns **whole-attempt** retries itself through the durable `SummaryGeneration` states (`ready` → `effect_pending` → `retry_wait`), which is where the captured policy already lives. - -```ts -// packages/agent/src/harness/compaction/compaction.ts - -/** Identifies a nested request within one structural attempt, so its intent row is deterministic. */ -export type NestedRequestLabel = "history" | "turn_prefix" | "branch"; - -/** Executes exactly ONE provider request. No retry inside — the caller owns that. */ -export type NestedRequest = ( - label: NestedRequestLabel, - aiContext: AiContext, - options: SimpleStreamOptions, - context: Context, -) => Promise; - -/** Default for existing callers: preserves today's in-helper retry behavior exactly. */ -export function retryingNestedRequest( - models: Models, model: Model, - retry: RetryPolicy | undefined, callbacks: RetryCallbacks | undefined, -): NestedRequest { - return (_label, aiContext, options, context) => - completeSimpleWithRetries(models, model, aiContext, options, retry, callbacks, context); -} -``` - -The drive implementation supplies its own, and each call is one intent, one admitted request, one usage commit. **It must reproduce every option `completeSimpleWithRetries` sets**, because dropping the seam below that function also drops the option composition it performs. Today that body is: - -```ts -// current packages/agent/src/harness/compaction/compaction.ts — for reference -const requestOptions: SimpleStreamOptions = { - ...options, - signal: context.abortSignal, - telemetryContext: context.telemetryContext, - cacheRetention: "none", // summaries are standalone; cache writes cannot be reused - sessionId: uuidv7(), // isolate routing, fresh per request -}; -``` - -So the drive runner composes the same four, with the signal and telemetry parent taken from the **admitted** context rather than the ambient one: - -```ts -// packages/agent/src/harness/runtime/drive/structural.ts — sketch -const request: NestedRequest = async (label, aiContext, options, context) => { - const index = nextRequestIndex(label); // 0 for history, 1 for turn_prefix - const usageId = lane.session.idGenerator.next(); - const intent = await commitRequestIntent(lane, drive, taskId, index, usageId); - // NestedRequest has no return channel for a declined intent, so this module-local - // signal unwinds to the structural procedure without starting the provider. - if (intent.kind !== "committed") throw new StructuralAttemptDeclined(intent); - - // Preparation finishes here, BEFORE the gate check, so admit() and the Models call stay - // adjacent (harness.md §4.2). The admitted context composes the invocation signal with - // the operation gate's signal. - const admitted = withAbortSignal(drive.gate.signal, context); - const requestOptions: SimpleStreamOptions = { - ...options, - signal: admitted.abortSignal, - telemetryContext: admitted.telemetryContext, - cacheRetention: "none", - sessionId: uuidv7(), - }; - - // ONE request. No retry here: a retryable failure unwinds to the attempt level, which - // commits retry_wait and starts a later numbered attempt (harness.md §3.9). - const message = await drive.gate.admit(() => - lane.models.completeSimple(model, aiContext, requestOptions)); - - await commitRequestUsage(lane, drive, taskId, usageId, message.usage); - return message; -}; -``` - -**`completeSimple` is the correct call here — do not "upgrade" it to `streamSimple`.** `packages/ai/src/models.ts` defines `completeSimple` as `this.streamSimple(model, context, options).result()`, so it is the same request with the same options, the same provider dispatch, and the same abort path. It also returns a started request synchronously from inside the closure, so `admit()` adjacency (harness.md §4.2) is identical. - -The difference that matters is what it does **not** hand you. harness.md §3.9 requires structural streams to emit no public assistant-message lifecycle and persist no `pendingAssistantFrames` list. Because `completeSimple` never yields the event stream, that prohibition cannot be violated by accident — which is a stronger guarantee than a comment next to a stream telling the next implementer not to use it. Should structural diagnostics ever be wanted, §3.9 already states the shape: a separate, explicitly scoped consumer, not a silent reuse of transcript-assistant semantics. - -The default `retryingNestedRequest` keeps calling `completeSimpleWithRetries` unchanged, so existing callers are byte-identical and the two paths cannot diverge. - -```ts -// packages/agent/src/harness/runtime/drive/structural.ts — module-private, never exported. - -/** A NestedRequest cannot return without an AssistantMessage, so a declined local intent - * unwinds to the owning structural procedure. This signal never escapes the module. */ -type StructuralIntentDecline = { kind: "state_changed" } | LostOwnership; -class StructuralAttemptDeclined extends Error { - readonly result: StructuralIntentDecline; - constructor(result: StructuralIntentDecline) { - super(`Structural request intent declined: ${result.kind}`); - this.name = "StructuralAttemptDeclined"; - this.result = result; - } -} - -type StructuralIntentResult = { kind: "committed" } | StructuralIntentDecline; -declare function commitRequestIntent( - lane: Lane, drive: Drive, taskId: string, index: number, usageId: string, -): Promise; - -async function runStructuralAttempt( - lane: Lane, drive: Drive, operation: Operation, decision: StructuralDecision, -): Promise { - try { - const result = await compact(preparation, lane.models, model, customInstructions, - thinkingLevel, /* retry */ undefined, /* callbacks */ undefined, drive.context, request); - return await publishStructuralResult(lane, drive, operation, decision, result); - } catch (error) { - if (error instanceof StructuralAttemptDeclined) { - return error.result.kind === "lost_ownership" - ? error.result - : { kind: "continue" }; - } - throw error; // AbortRequested, OperationEnded, provider errors - } -} -``` - -The throw site is the only one: `if (intent.kind !== "committed") throw new StructuralAttemptDeclined(intent);` - -A test must pin the option composition: assert the options reaching a faux `Models.completeSimple` from the drive path carry `cacheRetention: "none"`, a **distinct** `sessionId` per request within one split-turn attempt, the gate-composed signal, and the invocation telemetry parent — and that they match what `retryingNestedRequest` produces for the same input apart from the signal source. Assert also that no `message_start`/`message_update`/`message_end` event is emitted and no `pendingAssistantFrames` address is written during a structural attempt. - -Split-turn compaction makes two such calls — `"history"` then `"turn_prefix"` — so it produces two intents and two usage rows, which is exactly what harness.md §3.9 requires (`usageIds` accumulates). `AssistantMessage` already carries `usage`, so the runner persists what it needs without the compaction code knowing about storage. - -**Create** - `src/harness/runtime/drive/structural.ts` - `test/harness/runtime/drive-structural.test.ts` -**Modify** -| Path | Change | -|---|---| -| `src/harness/runtime/drive/checkpoint.ts` | threshold check keyed by `thresholdCheckedTriggerEntryId` | -| `src/harness/compaction/compaction.ts` | add `NestedRequestLabel`, `NestedRequest`, `retryingNestedRequest`; add an **optional trailing** `request?: NestedRequest` parameter to `compact()`, `generateSummaryWithUsage()`, and the private `generateTurnPrefixSummary()`. Each replaces its `completeSimpleWithRetries(models, model, aiContext, options, retry, callbacks, context)` call with `(request ?? retryingNestedRequest(models, model, retry, callbacks))(label, aiContext, options, context)`, label `"history"` in `generateSummaryWithUsage` and `"turn_prefix"` in `generateTurnPrefixSummary`; `compact()` threads its `request` into both. `models`/`model` stay — they are still needed for `maxTokens`/`contextWindow` budgeting. **No persistence here**: `compaction.ts` never writes `operationPreparation` or any other value | -| `src/harness/compaction/branch-summarization.ts` | add optional `request?: NestedRequest` to `GenerateBranchSummaryOptions`, defaulted in the destructuring in `generateBranchSummary` to `retryingNestedRequest(models, model, retry, callbacks)`, label `"branch"` | -| `src/harness/runtime/drive/structural.ts` | owns **all** persistence: writes `operationPreparation(operationId, taskId)` before the decision hook and revalidates it against its source on resume; commits each request intent and usage; commits the result entry and the terminal transaction | +Modify checkpoint handling for threshold checks and the existing compaction modules only to expose one-provider-request seams. -The parameter is optional and defaulted precisely so existing callers are untouched and keep in-helper retry: `packages/coding-agent/src/core/agent-session.ts` (`compact(…)` at the compaction path and `generateBranchSummary(…)`) and `packages/agent/test/harness/compaction.test.ts` compile and behave identically. Only `drive/structural.ts` passes a `request`, and when it does, `retry` is left `undefined` so no retry can happen below the attempt level. +### Structural request seam -**Behavior/invariants landed.** Preparation written before the decision hook and revalidated on resume; each nested request commits intent (index + reserved `usageId`), is admitted through the gate, then atomically writes usage and clears the request; crash after request one leaves the row and an uncertain attempt; an unknown-outcome attempt consumes an attempt; model disappearance between split requests fails in band; result entry id reserved once; standalone publication and terminal end share one transaction; in-run compaction resumes the stored checkpoint; threshold considered at most once per boundary; structural retry waiting is gated and its policy stated. +One structural attempt may contain one or two provider requests. Each request must have its own durable intent and usage row. Do not wrap a retrying helper in one intent. -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/runtime/drive-structural.test.ts` | threshold once per boundary; decline/success/crash resume; **one intent and one usage row per provider request**, asserted through `InstrumentedStorage` for a split-turn attempt (two of each, not one); a retryable failure inside an attempt produces **zero** extra provider calls before the attempt-level `retry_wait` commit; crash after request one leaves its usage row and an uncertain attempt; model disappearance between requests fails in band; unknown attempt consumption; preparation written and revalidated by `drive/structural.ts` only; navigation with and without summary | +The runtime-owned request callback performs: -**Commands.** `npm run check` · targeted vitest - -**Do not implement yet.** Reconciliation, public wiring. - -**Exit condition / review questions.** Is any structural state reachable without a driver? - ---- - -### M7 — Reconciliation and lifecycle (review checkpoint) - -**Goal.** Abort, close, fault, external finalization. Graph becomes total. - -**Read completely before editing** -- `src/harness/runtime/harness.ts` (`fault`, `close`), `src/harness/runtime/lane.ts` (`seal`) -- `src/harness/events.ts`, `src/harness/context.ts` -- `packages/agent/docs/harness.md` §4.6, §4.7, §4.9 - -**Create** -- `src/harness/runtime/drive/reconcile.ts` — reconciliation per phase **and** the package-private cancellation primitive `requestOperationAbort(lane, operationId, context)` -- `src/harness/runtime/drive.ts` — the total switch: `runDrive`, `hydrateSettled`, `dispatch`, `dispatchRun` (§2.4). Created **here**, not earlier, because this is the first milestone at which every module it imports (`checkpoint`, `generation`, `deferred`, `tools`, `structural`, `reconcile`, `terminal`, `recovery`) exists -- `test/harness/runtime/drive-reconcile.test.ts` -- `test/harness/runtime/drive-lifecycle.test.ts` -- `test/harness/runtime/drive-switch.test.ts` - -**Modify** -| Path | Change | -|---|---| -| `src/harness/runtime/lane.ts` | add package-internal `startDrive`, exact `removeDrive`, `abandonDrive`, finalization notification, and seal behavior; lifecycle tests call these directly with an installed `Drive`. `Lane.requestAbort` and public `drive` keep their `SliceNotImplemented` guards until M8 | -| `src/harness/runtime/harness.ts` | fault seals owners; close detaches non-cooperative effects | - -**External finalization — owner notification (approved).** harness.md §4.9 requires the in-process finalizer to notify a live owner *immediately after* the terminal commit, not to let it discover the loss later. Discovery-only would let the task keep a provider stream or tool running until its next lane job, and would risk a second end event. - -```ts -// packages/agent/src/harness/runtime/lane.ts -/** Package-internal M7 lifecycle seam; not part of AgentLane. */ -interruptOwnerForFinalization( - operationId: string, outcome: TerminalOperationOutcome, -): Drive | undefined { - const drive = this.activeDrive; - if (drive === undefined || drive.operationId !== operationId) return undefined; - - const settled: DriveOutcome = { kind: "settled", operationId, outcome }; - drive.finalizedOutcome = settled; // before OperationEnded can reach runDrive - drive.closeGate(new OperationEnded()); // stop/detach work immediately after commit - // Do not remove here: ownership spans event delivery and any remaining pass work. - return drive; // finalizer settles it after event delivery -} - -settleOwnerAfterFinalization(drive: Drive, outcome: DriveOutcome): void { - // Called only after the finalizer's Lane.command delivered its terminal event. - if (drive.passEnded) this.removeDrive(drive); - drive.settle(outcome); -} - -/** Package-internal M7 lifecycle seam; exercised through startDrive cancellation tests. */ -abandonDrive(drive: Drive, error: DriveAbandoned): void { - if (drive.finalizedOutcome !== undefined) return; // finalization already chose retained cleanup - drive.closeGate(error); - drive.fail(error); // shared pass completion; every joiner retries - this.removeDrive(drive); // replacement may recover effect_pending now -} +```text +reserve usage id +→ commit nested request intent { index, usageId } +→ compose gate signal and invocation telemetry Context +→ gate.admit(() => models.completeSimple(...)) +→ commit usage and clear/advance nested request intent ``` -Both methods run on `Lane`; procedures do not remove ownership. The external finalizer calls `interruptOwnerForFinalization` from its committed `materialize`, returns that owner in its internal command result, and calls `settleOwnerAfterFinalization(owner, { kind: "settled", operationId, outcome })` only after `Lane.command` has delivered the terminal event. If the pass ended first, this removes before settling; if event delivery/settlement happened first, pass `finally` removes later. Until both happen, same-id callers join the retained Drive rather than hydrating early. +Attempt-level retry is represented by the flat structural ready/effect-pending/retry-wait leaves. Existing non-harness callers retain their current in-helper retry behavior through a default request adapter. -`OperationEnded` is added to `execution/effect-gate.ts` alongside `AbortRequested` and `DriveAbandoned`. A non-cooperative effect that ignores the pulled signal is **observed and detached** under the same policy as close (§4.7): the pass does not block on it, and its later rejection is observed so it produces no unhandled rejection. It can no longer commit — every drive-owned commit is fenced by the current projection and exact Drive identity at commit admission. +Structural streams emit no transcript assistant lifecycle and persist no assistant frames. -**Behavior/invariants landed.** `requestOperationAbort` order: `beginAbort` → interrupt → commit marker → `signalAbort`. `runDrive` inspects the current owned operation and its control status **before** `before_drive`, and skips `before_drive` entirely when the operation is already `cancel_requested` — that pass only reconciles. Reconciliation walks `assistant.effect_pending`, `deferred`, `tools` (mixed statuses), `compaction`, `checkpoint`, `failure_drain`. Invocation cancellation (`DriveAbandoned`) never writes durable cancellation. A `lost_ownership` step terminates the pass immediately and writes nothing more. Close writes no synthetic settlement, does not block on a non-cooperative effect, and produces no unhandled rejection. External finalization interrupts the owner synchronously after the terminal commit, delivers the terminal event, then settles it; the old task stops without writing and resolves from `finalizedOutcome` (falling back to `laneLastResult` when it was finalized by a replacement process), and emits no duplicate terminal event. +### Required behavior -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/runtime/drive-reconcile.test.ts` | `requestOperationAbort` exercised directly with a concrete `Lane` and `Drive`, not through `Lane.requestAbort`; abort before/after admission at each admit site; mid-batch abort; reconciliation per phase; `aborted` response always under `cancel_requested` | -| `test/harness/runtime/drive-switch.test.ts` | `before_drive` runs once, after the current owned operation is inspected; `before_drive` is **not** invoked for an already `cancel_requested` operation; `continue` requires a replaced projection and re-dispatches; unchanged `continue` faults instead of spinning; `lost_ownership` ends the pass with zero further commits; external finalization → `hydrateSettled` returns the same outcome; foreign operation on the fence → `OperationMismatch` | -| `test/harness/runtime/drive-lifecycle.test.ts` | close **before** admission returns `Result.err(Closed)`; close **during** admitted work rejects `HarnessClosed` (these two are asserted separately, never with a shared loose matcher); non-cooperative effect; fault sealing; invocation cancel abandons at `effect_pending` with no durable write | -| `test/harness/runtime/drive-lifecycle.test.ts` (finalization) | finalize while a **provider stream** is active, and again while a **tool** is active; also let an effect return successfully after committed finalization but before terminal event delivery, and attempt a same-id `drive` during that delivery: the owner's gate closes and its signal fires within committed materialization; the terminal event is delivered before shared completion settles; a cooperative effect rejects with `OperationEnded` and resolves through `drive.finalizedOutcome` **without entering a lane job**, while a successful return racing event delivery reaches `lost_ownership` but still returns `finalizedOutcome`; neither path settles/fails the Drive from the pass continuation or writes after the terminal commit (asserted through `InstrumentedStorage`: zero commit attempts after the terminal one); exactly **one** `run_end` is delivered; `accept` returns `LaneBusy` until both event-delivered settlement and the owner's `finally` have occurred, then succeeds; a non-cooperative effect that ignores the signal delays nothing and produces no unhandled rejection | -| `test/harness/runtime/drive-lifecycle.test.ts` (catch boundary) | each signal from admitted work reaches its own arm and no other: `OperationEnded` → settled outcome, no second end event; `AbortRequested` → marker awaited, reconciliation runs, `aborted` under `cancel_requested`; `DriveAbandoned` → pass rejects, state stays `effect_pending`, **zero** durable writes, next drive recovers it as an orphan; `HarnessClosed` and `HarnessFault` → propagate unchanged, no synthetic settlement; `OperationMismatch` from `hydrateSettled` → propagates to `Result.err`. Every arm ends with the owner removed exactly once (§2.1 ordering) | +- Preparation is written before the decision hook and reused after reopen. +- Threshold checks happen at most once per trigger boundary. +- An unknown structural attempt consumes an attempt; it does not resume request two. +- Model disappearance fails in band. +- Standalone result publication and terminal cleanup are one transaction. +- In-run success/decline restores the captured checkpoint according to reason. +- Unsummarized navigation moves tip/label and terminates in one transaction. -**Commands.** `npm run check` · targeted vitest +### Focused validation -**Do not implement yet.** Public wiring (`SliceNotImplemented` guards remain on all sixteen methods, `requestAbort` included). +Cover every structural `at` leaf, split-turn request accounting, crashes after each nested request, threshold deduplication, hook decline/result, model absence, retry cap, navigation validation, and publication/terminal atomicity. -**Exit condition / review questions.** Is every `RunPhase`, `CompactionState`, and `NavigationState` reachable by the classifier now driveable *and* reconcilable? Does `drive.ts` import any module that does not exist? Is `Lane.requestAbort` still guarded? +Public drive remains disabled. ---- +## 6. M7 — Cancellation reconciliation and total switch -### M8 — Public surfaces +### Goal -**Goal.** Remove the guards; wire ownership and every public method. +Make the internal graph total without public wiring. -**Read completely before editing** -- `src/harness/agent-harness.ts` (`AgentLane`, `AgentHarness`) -- `src/harness/runtime/lane.ts` (all `SliceNotImplemented` stubs) -- `test/harness/runtime/{accept,lane,harness,watch}.test.ts` +Create: -**Create** -- `test/harness/runtime/drive-ownership.test.ts` -- `test/harness/runtime/drive-surfaces.test.ts` -- `test/harness/runtime/drive-crash-matrix.test.ts` +- `src/harness/runtime/drive/reconcile.ts` +- `src/harness/runtime/drive.ts` +- focused reconciliation and switch tests -**Modify** -| Path | Change | -|---|---| -| `src/harness/runtime/lane.ts` | **ordered**: (1) `accept` accepts `kind: "compaction"` and `kind: "navigation"` — today both throw `SliceNotImplemented` at the top of `accept`, so a standalone structural operation cannot be admitted at all; (2) implement the public `drive` claim/join/hydration surface (§2.3) using M7's internal lifecycle methods; `accept` returns `LaneBusy` while an owner exists; (3) expose `requestAbort` by delegating to the M7 primitive; (4) only then the convenience compositions `prompt`, `skill`, `promptFromTemplate`, `compact`, `navigateTree`, `resume`, `abort`, `steer`, `followUp`, `nextRun`, `cancelQueued`, `recordUsage`, `waitForIdle`, `runWhenIdle`; `inspectExecution` reports `running` when owned | -| `src/harness/runtime/restore.ts` | restore every phase projection | -| `src/harness/telemetry.ts` | drive spans | -| `src/harness/runtime/types.ts` | `SliceNotImplemented` retained only for `watchSession` | +### `requestOperationAbort` -`compact()` and `navigateTree()` are *accept + drive*, so they cannot be written before `accept` admits their operation kinds. Doing the composition first would force a second admission path that only the convenience method can reach. +Implement the package-private expected-id primitive: -**Behavior/invariants landed.** Owner-before-idle claim order; join observes delivered events; usage row `seq` from `CommitResult`, and usage-event totals read `commit.stats.usage` inside `events(commit)` — no process accumulator (§2.12); every hook/event/observation carries the emitting Context. Pi-ai's per-stream frame encoder treats provider `partial` as a shared live helper and removes already-covered queued deltas without cloning the growing message per event; the crash matrix pins synchronous shared-partial bursts. `AgentHarness.watchSession` is **explicitly deferred** and keeps its rejection. +1. synchronously `beginAbort` on a matching live Drive; +2. commit or observe `cancel_requested` on the Session line; +3. drain run steer/follow-up ids into control without deleting their payloads; +4. call `signalAbort` after commit; +5. return once cancellation is durable. -**Focused tests** -| Path | Theme | -|---|---| -| `test/harness/runtime/drive-ownership.test.ts` | join before hydration; same-id join observes delivered events; mismatch ids. Both paths use the same result/error adapter: normal outcome → `Result.ok`; `OperationMismatch` and `Closed` → `Result.err`; `HarnessClosed`, `HarnessFault`, `DriveAbandoned` → rejection. Installer cancellation rejects installer and existing joiners with `DriveAbandoned`; joiner cancellation rejects only that joiner and leaves the Drive installed | -| `test/harness/runtime/drive-ownership.test.ts` (independent completion/removal) | run each case with a **non-cooperative provider** and again with a **non-cooperative tool** that ignores its signal and never settles. **External finalization**: after the terminal event is delivered, public completion resolves with the finalizer's outcome while the effect is still stuck; `accept` returns `LaneBusy` throughout; exactly **one** `run_end` is delivered; after the effect finally returns the owner clears and `accept` succeeds. **Invocation abandonment**: the public promise rejects `DriveAbandoned`, a new `drive` claims the lane **before** the old effect returns, durable state is still `effect_pending`, and the new pass runs orphan recovery. **Close/fault**: resolves without waiting; public promises reject `HarnessClosed`/`HarnessFault`. In every case the detached pass produces no unhandled rejection, and `Drive.settle`/`Drive.fail` and exact `removeDrive` remain idempotent | -| `test/harness/runtime/drive-ownership.test.ts` (ABA fencing) | abandon an invocation whose effect is still running, let a **replacement owner claim the same `operationId`**, then let the old effect return: its transition planner and its progress writes both decline via `isDriveActive` even though the operation id is unchanged; zero commits attributed to the old task (asserted through `InstrumentedStorage`); the replacement owner is **not** cleared by the old task's cleanup; the replacement's own commits succeed | -| `test/harness/runtime/drive-surfaces.test.ts` | `accept({kind:"compaction"})` and `accept({kind:"navigation"})` admit and drive before any convenience method is used; convenience = accept + drive; `resume`; `requestAbort` through the public method reaches the M7 primitive; `Closed` returned not thrown; listeners settle before the next procedure hook; usage totals per settlement equal `getStats()`; **reopen a session with historical usage** → the first new usage event's totals include the history; **two lanes committing usage concurrently** → each event's totals match its own commit position and the final totals equal `getStats()`; Context lineage | -| `test/harness/runtime/drive-crash-matrix.test.ts` | release N commits → discard → reopen → drive, for every N in every phase path; every gated boundary exposes its exact committed writes; gated vs ungated byte-identical writes; a provider that publishes start, block start, and deltas synchronously against one shared live partial persists a reducible non-duplicated frame sequence | +With no Drive it commits the same marker and starts no pass. -**Commands.** `npm run check` · `./test.sh` +### Reconciliation -**Do not implement yet.** `watchSession`. +Cancellation is checked before `before_drive` and ordinary dispatch. Reconciliation is a single switch over flat `state.at` and never starts new ordinary work. -**Exit condition / review questions.** Any public path reachable over a partial graph? Any `SliceNotImplemented` left besides `watchSession`? Does any usage total come from anywhere but `CommitResult.stats`? +It must handle: ---- +- assistant and deferred effects with live or reconstructed results; +- planned/effect-pending/outcome-ready tool calls; +- accepted deferred writes; +- structural process-local results; +- retry waits, checkpoints, suspended deferred work, and failure drain; +- best-effort deferred-provider cancellation; +- aborted terminal cleanup. -### Post-WP05 lifecycle simplification review +### Total switch -After M8 and the crash matrix are complete, revisit the abort/close/crash design before treating the lifecycle machinery as stable. This review does not block the current milestone sequence. +`runtime/drive.ts` owns one direct `state.at` switch. It imports only complete procedure modules. It has no graph table, action interpreter, ownership-loss arm, external-finalization arm, or storage-state reload. -Start from these intended semantics: +A `continue` result must correspond to a replaced `Lane.state` projection; unchanged continuation is an invariant defect. -- close and process crash leave the same durable restart point; close differs only by cooperatively sealing admission, signalling live work, and draining already-admitted mutations; -- explicit abort is durable because it changes recovery policy: live cooperative effects settle normally under `cancel_requested`, while a replacement process reconciles rather than retrying assistant requests, replaying safe tools, polling deferred work, or starting new structural work; -- process-local invocation cancellation is not durable abort. +Public methods remain guarded through M7. -Audit whether the implementation can be simplified by replacing propagated `lost_ownership` result unions with one internal sentinel caught at the outer Drive boundary, centralizing cancellation classification at effect/phase boundaries, and removing owner-retention, external-finalization, or non-cooperative-effect machinery that the final required scenarios do not justify. Preserve only complexity demonstrated by concrete close/crash/abort traces and crash-matrix tests. +## 7. M8 — Public surfaces ---- +Remove execution guards only after every state and reconciliation path is total. -## 4. File manifest +Order: -**Delete** -- `packages/agent/src/harness/execution/breakpoint.ts` +1. admit compaction/navigation requests; +2. implement `drive` install/join/latest-result hydration; +3. expose `requestAbort`; +4. add convenience compositions; +5. add queues/configuration/usage/idle surfaces; +6. retain `watchSession` as the sole `SliceNotImplemented` method. -Milestone in parentheses where creation order matters. +### Drive observation -**Create** -- `packages/agent/src/harness/runtime/drive.ts` (**M7** — not before; it imports every procedure module) -- `packages/agent/src/harness/runtime/drive/checkpoint.ts` -- `packages/agent/src/harness/runtime/drive/generation.ts` -- `packages/agent/src/harness/runtime/drive/recovery.ts` -- `packages/agent/src/harness/runtime/drive/deferred.ts` -- `packages/agent/src/harness/runtime/drive/tools.ts` -- `packages/agent/src/harness/runtime/drive/structural.ts` -- `packages/agent/src/harness/runtime/drive/reconcile.ts` -- `packages/agent/src/harness/runtime/drive/terminal.ts` -- `packages/agent/src/harness/runtime/progress.ts` -- `packages/agent/src/harness/session/testing/gating-storage.ts` -- `packages/agent/test/harness/gating-storage.test.ts` -- `packages/agent/test/harness/runtime/drive-terminal.test.ts` -- `packages/agent/test/harness/runtime/drive-generation.test.ts` -- `packages/agent/test/harness/runtime/drive-retry-deferred.test.ts` -- `packages/agent/test/harness/runtime/drive-tools.test.ts` -- `packages/agent/test/harness/runtime/drive-structural.test.ts` -- `packages/agent/test/harness/runtime/drive-reconcile.test.ts` -- `packages/agent/test/harness/runtime/drive-switch.test.ts` -- `packages/agent/test/harness/runtime/drive-lifecycle.test.ts` -- `packages/agent/test/harness/runtime/drive-ownership.test.ts` -- `packages/agent/test/harness/runtime/drive-surfaces.test.ts` -- `packages/agent/test/harness/runtime/drive-crash-matrix.test.ts` +A caller checks its signal before installation. After installation every caller uses observation-only cancellation: -**Modify** -- `packages/agent/src/harness/agent-harness.ts` -- `packages/agent/src/harness/result.ts` -- `packages/agent/src/harness/types.ts` -- `packages/agent/src/harness/hooks.ts` -- `packages/agent/src/harness/telemetry.ts` -- `packages/agent/src/harness/execution/effect-gate.ts` -- `packages/agent/src/harness/execution/assistant.ts` -- `packages/agent/src/harness/execution/tools.ts` -- `packages/agent/src/harness/compaction/compaction.ts` -- `packages/agent/src/harness/compaction/branch-summarization.ts` -- `packages/agent/src/harness/tools/bash.ts` -- `packages/agent/src/harness/runtime/lane.ts` -- `packages/agent/src/harness/runtime/harness.ts` -- `packages/agent/src/harness/runtime/types.ts` -- `packages/agent/src/harness/runtime/restore.ts` -- `packages/ai/src/models.ts` -- `packages/coding-agent/src/core/model-runtime.ts` -- `packages/agent/src/harness/session/session.ts` -- `packages/agent/src/harness/session/types.ts` -- `packages/agent/src/harness/session/commit.ts` -- `packages/agent/src/harness/session/in-memory-storage-state.ts` -- `packages/agent/src/harness/session/memory.ts` -- `packages/agent/src/harness/session/jsonl/storage.ts` -- `packages/agent/src/harness/session/testing/conformance/storage.ts` -- `packages/agent/src/harness/session/testing/index.ts` -- `packages/session-backends/sqlite-node/src/sqlite/storage.ts` -- `packages/session-backends/sqlite-node/test/storage-conformance.test.ts` -- `packages/session-backends/sqlite-node/test/storage.test.ts` -- `packages/agent/test/harness/execution-primitives.test.ts` -- `packages/agent/test/harness/types.test.ts` -- `packages/agent/test/harness/runtime/test-utils.ts` -- `packages/agent/test/harness/runtime/accept.test.ts` -- `packages/agent/test/harness/runtime/lane.test.ts` -- `packages/agent/test/harness/runtime/harness.test.ts` -- `packages/agent/test/harness/runtime/restore.test.ts` -- `packages/agent/test/harness/runtime/watch.test.ts` -- `packages/agent/docs/harness.md` -- `packages/agent/docs/work-packages/02-atomic-run-acceptance.md` -- `packages/agent/docs/work-packages/03-remove-drive-deadlines.md` -- `packages/agent/docs/work-packages/04-mutation-publication.md` -- `packages/agent/CHANGELOG.md` -- `packages/protocol/src/harness.ts` -- `packages/protocol/test/protocol.test.ts` -- `packages/protocol/CHANGELOG.md` -- `packages/coding-agent/src/experimental/harness-wire-adapter.ts` -- `packages/coding-agent/test/experimental-harness-wire-adapter.test.ts` -- `packages/coding-agent/CHANGELOG.md` +```text +awaitWithContext(drive.completion, callerContext) +``` -**Conditional** -| Path | Decide by | -|---|---| -| `packages/agent/src/harness/session/values.ts` | create a new address only if a required durable fact has none; default is no change | +`awaitWithContext` rejects that waiter without cancelling the underlying completion. Installer and joiner have identical lifetime semantics. -`session/types.ts` is listed under **Modify** rather than Conditional because §2.12 requires the `CommitResult.stats` field. That is an in-memory result shape, not a durable record, so it needs no migration. Any change to a *durable* shape (`RunPhase`, `Generation`, `OperationMeta`, `LaneLastResult`, …) remains forbidden without a stated migration. +### Close/fault ---- +Public observations race Harness close/fault so they reject promptly. Every detached pass promise has an attached rejection observer. Late effect settlement reaches the sealed mutation line and rejects without a durable write. -## 5. Exclusions +### Focused validation -Do not introduce: a `Deps` object; a generic scheduler, effect combinator, or action interpreter; callbacks that hide a commit (`classifyAndCommit`-style); string addresses; a `delete_prefix` write op; a parallel `Line`/`Tx` framework; activation epochs or tokens; manual-action state; changes to durable shapes (`RunPhase`/`Generation`/`CompactionSettings`/`LaneConfiguration`). +Cover: -Also do not introduce: focused or partial durable readers, read-budget accounting, a read cache or memoized read layer, or a batching `getValues` API (§2.5); a process-local usage accumulator (§2.12); `any`, `Lane`, or variance widening to make a generic assignment compile (§2.11); a `drive.ts` switch that imports modules from a later milestone (§2.4). +- one install and same-id joins; +- stale-id isolation and latest-result hydration; +- caller cancellation before and after installation; +- one caller cancelling while other observers remain; +- close/fault during cooperative and non-cooperative effects; +- accept/drive and convenience equivalence; +- full crash matrix across every flat leaf; +- no unhandled detached rejection; +- no `SliceNotImplemented` except `watchSession`. -Classification is a pure function returning a typed decision; the calling procedure keeps its writes visible. +## 8. Module boundaries ---- +```text +session/** durable storage; imports no runtime module +execution/** neutral provider/tool/gate mechanics +runtime/types.ts LaneState, Drive, command decisions +runtime/progress.ts frame/tool progress channels +runtime/drive/*.ts direct procedures +runtime/drive.ts total flat switch, created only in M7 +runtime/lane.ts Lane actor and public surfaces +runtime/harness.ts Harness lifecycle and Lane composition +``` -## 6. Final validation +Procedure modules import concrete `Lane` type-only. `TContext` remains `object | undefined` invariant. No `any`, `Lane`, `as unknown as`, `@ts-expect-error`, inline imports, parameter properties, enums, or other non-erasable TypeScript syntax. + +## 9. Exclusions + +Do not introduce: + +- generic scheduler, graph, action interpreter, or effect-plan DSL; +- a second mutation line or transaction framework; +- a dependency/capability facade around Lane; +- expected-`at` runtime checks for the sole top-level writer; +- process-local Drive replacement or caller-owned lifetime; +- external finalization; +- storage rereads of authoritative control state; +- string addresses or prefix-delete writes; +- read caches, read budgets, or generic `getValues` batching; +- process-local usage accumulators; +- compatibility aliases for the pre-flat WIP durable shape. + +Procedure-specific writes, effect admission, settlement classification, and event construction remain visible at their call sites. The Lane helpers centralize only the transaction mechanics common to every transition. + +## 10. Validation and reviews + +After every code stage: ```bash npm run check -./test.sh -grep -rn "Breakpoint\|action_required\|ActionInfo\|ActionRequired\|peekAction\|executeAction\|runToCompletion\|SettledResumeOutcome" \ - packages/*/src packages/*/test packages/agent/docs/harness.md -grep -rn "delete_prefix\|interface Deps\b" packages/agent/src -grep -rn "SliceNotImplemented" packages/agent/src/harness/runtime # watchSession only -grep -rn ": any\b\|\|as unknown as\|@ts-expect-error" packages/agent/src/harness/runtime -grep -rn 'kind: "advance"\|kind: "reload"' packages/agent/src/harness/runtime # must stay empty -grep -n "runtime" packages/agent/src/index.ts # must stay empty (§2.11) -# CommitResult.stats reaches all THREE backends, not just the two in packages/agent: -grep -rn "firstSeq" packages/agent/src packages/agent/test packages/session-backends -grep -rn "prepared.result" packages/agent/src packages/session-backends # each composes stats ``` -The storage conformance suite runs against Memory, JSONL, **and** SQLite; all three must report `commit(…).stats` equal to `getStats()` after every commit and after reopen. A green `packages/agent` run alone does not prove this — `packages/session-backends/sqlite-node/test/storage-conformance.test.ts` must pass too. +Run each modified focused test file from the package root. Do not invoke the full Vitest suite directly. Run `./test.sh` only for the final package validation or when explicitly requested. -Stop condition: grep results as specified; every `RunPhase`/`CompactionState`/`NavigationState` driveable and reconcilable; every milestone exit condition met; every milestone compiles at its own commit with no forward imports; **all three storage backends pass conformance**; reviews at M1, M3, M5, M7 and final report no findings; `harness.md` updated in the same commits as its code. +Review checkpoints remain mandatory at M7 and final completion. Delegated reviews use provider `anthropic` and model `claude-fable-5`. + +Final greps: + +```bash +rg 'lost_ownership|LostOwnership|DriveAbandoned|commandDriveOwned|installerSignal|finalizedOutcome|OperationEnded' \ + packages/agent/src/harness packages/agent/test/harness +rg 'SliceNotImplemented' packages/agent/src/harness/runtime +rg ': any\\b||as unknown as|@ts-expect-error' packages/agent/src/harness/runtime +``` + +Final exit conditions: + +- every flat operation leaf is driveable and reconcilable; +- public primitive/convenience behavior is equivalent; +- every focused test and backend conformance path passes; +- public drive exposes no partial graph; +- `watchSession` is the only deferred public method; +- independent final review reports no blocker. diff --git a/packages/agent/src/harness/execution/effect-gate.ts b/packages/agent/src/harness/execution/effect-gate.ts index e722f4b49..903301f23 100644 --- a/packages/agent/src/harness/execution/effect-gate.ts +++ b/packages/agent/src/harness/execution/effect-gate.ts @@ -9,14 +9,6 @@ export class AbortRequested extends Error { } } -/** Invocation cancellation or owner abandonment. Never durable cancellation. */ -export class DriveAbandoned extends Error { - constructor() { - super("Drive pass was abandoned by its installing invocation"); - this.name = "DriveAbandoned"; - } -} - /** Procedure-facing synchronous admission capability for one drive pass. */ export interface Gate { readonly signal: AbortSignal; diff --git a/packages/agent/src/harness/runtime/drive/checkpoint.ts b/packages/agent/src/harness/runtime/drive/checkpoint.ts index 965772ed7..96bb8bd8b 100644 --- a/packages/agent/src/harness/runtime/drive/checkpoint.ts +++ b/packages/agent/src/harness/runtime/drive/checkpoint.ts @@ -3,49 +3,27 @@ import type { HarnessEvent, TerminalOperationOutcome } from "../../agent-harness import { insertEntry } from "../../session/commit.ts"; import { buildSessionContext } from "../../session/context.ts"; import { SessionInvariantError } from "../../session/session.ts"; -import type { - CheckpointPhase, - Entry, - LaneLastResult, - MessageEntry, - NewEntry, - RunState, - SettledAssistantMessage, -} from "../../session/types.ts"; import { - branchTip, - deleteValue, - laneLastResult as laneLastResultValue, - laneState as laneStateValue, - operationState as operationStateValue, - pendingEntry, - setValue, -} from "../../session/values.ts"; + checkpointDataOf, + type Entry, + type LaneLastResult, + type MessageEntry, + type NewEntry, + type RunAssistantReadyOperation, + type RunCheckpointOperation, + type RunStartingOperation, + runScopeOf, + type SettledAssistantMessage, +} from "../../session/types.ts"; +import { branchTip, deleteValue, pendingEntry, setValue } from "../../session/values.ts"; import type { Lane } from "../lane.ts"; -import type { Drive, LaneCommand, LostOwnership, ProcedureResult } from "../types.ts"; +import type { Drive, ProcedureResult } from "../types.ts"; import { operationCleanupWrites } from "./terminal.ts"; -type ReadResult = { kind: "value"; value: T } | { kind: "state_changed" } | LostOwnership; - function isSettledAssistant(message: AgentMessage): message is SettledAssistantMessage { return message.role === "assistant" && message.stopReason !== "pending"; } -function sameCheckpoint(current: CheckpointPhase, expected: CheckpointPhase): boolean { - return ( - current.triggerEntryId === expected.triggerEntryId && - current.thresholdCheckedTriggerEntryId === expected.thresholdCheckedTriggerEntryId && - current.skipInboxOnce === expected.skipInboxOnce && - current.continuation.kind === expected.continuation.kind && - (current.continuation.kind !== "need_assistant" || - (expected.continuation.kind === "need_assistant" && - current.continuation.overflowRecoveryUsed === expected.continuation.overflowRecoveryUsed)) && - (current.continuation.kind !== "may_finish" || - (expected.continuation.kind === "may_finish" && - current.continuation.includeFinalAssistant === expected.continuation.includeFinalAssistant)) - ); -} - function lifecycleEvents(entry: Entry, lane: string, runId: string): HarnessEvent[] { return entry.type === "message" ? [ @@ -59,21 +37,11 @@ function lifecycleEvents(entry: Entry, lane: string, runId: string): HarnessEven async function readRunContext( lane: Lane, drive: Drive, - phase: CheckpointPhase, -): Promise> { - const entries = await lane.commandDriveOwned>( - drive, - async (state, reader): Promise>> => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Checkpoint context reached a non-run operation"); - } - if (operation.state.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - if (operation.state.phase.kind !== "checkpoint" || !sameCheckpoint(operation.state.phase, phase)) { - throw new SessionInvariantError("Checkpoint context lost its durable boundary"); - } + checkpoint: RunCheckpointOperation, +): Promise { + const entries = await lane.advanceOperation( + checkpoint, + async (state, _current, _meta, reader) => { if (state.tipId === null) throw new SessionInvariantError("Run checkpoint has no Branch tip"); const path = ( await reader.scanBranch( @@ -81,59 +49,42 @@ async function readRunContext( drive.context, ) ).reverse(); - return { kind: "return", result: { kind: "value", value: path } as const }; + return { kind: "return", result: path }; }, drive.context, ); - if (entries.kind !== "value") return entries; - return { - kind: "value", - value: await buildSessionContext( - entries.value, - { entryProjectors: lane.readConfig().entryProjectors }, - drive.context, - ), - }; + return entries === undefined + ? undefined + : buildSessionContext(entries, { entryProjectors: lane.readConfig().entryProjectors }, drive.context); } /** Consume before_run and commit the initial checkpoint. */ export async function startRun( lane: Lane, drive: Drive, - run: RunState, + run: RunStartingOperation, ): Promise { - if (run.phase.kind !== "starting") throw new SessionInvariantError("startRun requires the starting phase"); - const prompt = await lane.commandDriveOwned>( - drive, - async (state, reader): Promise>> => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run" || operation.meta.intent.kind !== "run") { - throw new SessionInvariantError("before_run reached a non-run operation"); - } - if (operation.state.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - if (operation.state.phase.kind !== "starting") { - throw new SessionInvariantError("before_run lost the starting boundary"); - } - const entries = await reader.getEntries(operation.meta.intent.promptEntryIds, drive.context); - const messages = operation.meta.intent.promptEntryIds.map((id) => { + const prompt = await lane.advanceOperation( + run, + async (_state, _current, meta, reader) => { + if (meta.intent.kind !== "run") throw new SessionInvariantError("Run operation has non-run intent"); + const entries = await reader.getEntries(meta.intent.promptEntryIds, drive.context); + const messages = meta.intent.promptEntryIds.map((id) => { const entry = entries.get(id); if (entry?.type !== "message") { throw new SessionInvariantError(`Run prompt entry ${id} is missing its message`); } return entry.message; }); - return { kind: "return", result: { kind: "value", value: messages } as const }; + return { kind: "return", result: messages }; }, drive.context, ); - if (prompt.kind === "lost_ownership") return prompt; - if (prompt.kind === "state_changed") return { kind: "continue" }; + if (prompt === undefined) return { kind: "continue" }; const hook = await lane.hooks.runWithGate( "before_run", - { lane: lane.name, runId: drive.operationId, prompt: prompt.value, resources: lane.readConfig().resources }, + { lane: lane.name, runId: drive.operationId, prompt, resources: lane.readConfig().resources }, drive.gate, drive.context, ); @@ -145,19 +96,9 @@ export async function startRun( } const reserved = injected.map((message) => ({ id: lane.session.idGenerator.next(), message })); - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("before_run settlement reached a non-run operation"); - } - if (operation.state.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (operation.state.phase.kind !== "starting") { - throw new SessionInvariantError("before_run settlement lost the starting boundary"); - } + const result = await lane.advanceOperation( + run, + (state, current) => { let parentId = state.tipId; const entries = reserved.map(({ id, message }) => { const entry: NewEntry = { id, parentId, type: "message", message }; @@ -166,26 +107,20 @@ export async function startRun( }); const triggerEntryId = parentId; if (triggerEntryId === null) throw new SessionInvariantError("Run start has no trigger entry"); - const nextState: RunState = { - ...operation.state, - phase: { - kind: "checkpoint", - continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, - triggerEntryId, - }, + const nextState: RunCheckpointOperation = { + ...runScopeOf(current), + at: "run.checkpoint", + continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, + triggerEntryId, }; return { kind: "commit", writes: [ ...entries.map((entry) => insertEntry(entry)), ...(entries.length === 0 ? [] : [setValue(branchTip(lane.name), triggerEntryId)]), - setValue(operationStateValue(drive.operationId), nextState), ], - next: { - ...state, - tipId: triggerEntryId, - operation: { meta: operation.meta, state: nextState }, - }, + operationState: nextState, + lane: { tipId: triggerEntryId }, materialize: () => ({ kind: "continue" }) as const, events: (commit) => entries.flatMap((entry, index) => @@ -199,27 +134,17 @@ export async function startRun( }, drive.context, ); + return result ?? { kind: "continue" }; } async function applyPendingWrites( lane: Lane, drive: Drive, - phase: CheckpointPhase, + checkpoint: RunCheckpointOperation, ): Promise { - return lane.commandDriveOwned( - drive, - async (state, reader) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Pending-write drain reached a non-run operation"); - } - const current = operation.state; - if (current.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (current.phase.kind !== "checkpoint" || !sameCheckpoint(current.phase, phase)) { - throw new SessionInvariantError("Pending-write drain lost its checkpoint boundary"); - } + const result = await lane.advanceOperation( + checkpoint, + async (state, current, _meta, reader) => { const ids = current.inbox.writes; if (ids.length === 0) return { kind: "return", result: { kind: "continue" } as const }; const pending = await Promise.all( @@ -248,29 +173,26 @@ async function applyPendingWrites( } return entry; }); - const nextPhase: CheckpointPhase = + const scope = { ...runScopeOf(current), inbox: { ...current.inbox, writes: [] } }; + const nextState: RunCheckpointOperation = triggerEntryId === undefined - ? current.phase + ? { ...scope, at: "run.checkpoint", ...checkpointDataOf(current) } : { - kind: "checkpoint", + ...scope, + at: "run.checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId, skipInboxOnce: true, }; - const nextState: RunState = { - ...current, - phase: nextPhase, - inbox: { ...current.inbox, writes: [] }, - }; return { kind: "commit", writes: [ ...entries.map((entry) => insertEntry(entry)), ...ids.map((id) => deleteValue(pendingEntry(id))), setValue(branchTip(lane.name), parentId), - setValue(operationStateValue(drive.operationId), nextState), ], - next: { ...state, tipId: parentId, operation: { meta: operation.meta, state: nextState } }, + operationState: nextState, + lane: { tipId: parentId }, materialize: () => ({ kind: "continue" }) as const, events: (commit) => entries.flatMap((entry, index) => @@ -284,28 +206,18 @@ async function applyPendingWrites( }, drive.context, ); + return result ?? { kind: "continue" }; } async function consumeQueuedMessages( lane: Lane, drive: Drive, - phase: CheckpointPhase, + checkpoint: RunCheckpointOperation, queue: "steer" | "followUp", ): Promise { - return lane.commandDriveOwned( - drive, - async (state, reader) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Queue drain reached a non-run operation"); - } - const current = operation.state; - if (current.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (current.phase.kind !== "checkpoint" || !sameCheckpoint(current.phase, phase)) { - throw new SessionInvariantError("Queue drain lost its checkpoint boundary"); - } + const result = await lane.advanceOperation( + checkpoint, + async (state, current, _meta, reader) => { const allIds = current.inbox[queue]; const mode = queue === "steer" ? current.settings.steeringMode : current.settings.followUpMode; const ids = mode === "all" ? allIds : allIds.slice(0, 1); @@ -339,14 +251,12 @@ async function consumeQueuedMessages( }); const triggerEntryId = entries.at(-1)?.id; if (triggerEntryId === undefined) throw new SessionInvariantError("Queue drain produced no trigger entry"); - const nextState: RunState = { - ...current, - phase: { - kind: "checkpoint", - continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, - triggerEntryId, - skipInboxOnce: true, - }, + const nextState: RunCheckpointOperation = { + ...runScopeOf(current), + at: "run.checkpoint", + continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, + triggerEntryId, + skipInboxOnce: true, inbox: { ...current.inbox, [queue]: allIds.slice(ids.length) }, }; return { @@ -355,13 +265,9 @@ async function consumeQueuedMessages( ...entries.map((entry) => insertEntry(entry)), ...ids.map((id) => deleteValue(pendingEntry(id))), setValue(branchTip(lane.name), triggerEntryId), - setValue(operationStateValue(drive.operationId), nextState), ], - next: { - ...state, - tipId: triggerEntryId, - operation: { meta: operation.meta, state: nextState }, - }, + operationState: nextState, + lane: { tipId: triggerEntryId }, materialize: () => ({ kind: "continue" }) as const, events: (commit) => [ ...entries.flatMap((entry, index) => @@ -383,19 +289,19 @@ async function consumeQueuedMessages( }, drive.context, ); + return result ?? { kind: "continue" }; } async function finishRun( lane: Lane, drive: Drive, - phase: CheckpointPhase, + checkpoint: RunCheckpointOperation, ): Promise { - const context = await readRunContext(lane, drive, phase); - if (context.kind === "lost_ownership") return context; - if (context.kind === "state_changed") return { kind: "continue" }; + const context = await readRunContext(lane, drive, checkpoint); + if (context === undefined) return { kind: "continue" }; const hook = await lane.hooks.runWithGate( "before_run_end", - { lane: lane.name, runId: drive.operationId, messages: context.value }, + { lane: lane.name, runId: drive.operationId, messages: context }, drive.gate, drive.context, ); @@ -404,22 +310,11 @@ async function finishRun( const followUpMessage: AgentMessage | undefined = followUp === undefined ? undefined : { role: "user", content: followUp, timestamp: Date.now() }; - return lane.commandDriveOwned( - drive, - async (state, reader) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Run finish reached a non-run operation"); - } - const current = operation.state; - if (current.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (current.phase.kind !== "checkpoint" || !sameCheckpoint(current.phase, phase)) { - throw new SessionInvariantError("Run finish lost its checkpoint boundary"); - } + const result = await lane.advanceOperation( + checkpoint, + async (state, current, _meta, reader) => { if ( - current.phase.continuation.kind !== "may_finish" || + current.continuation.kind !== "may_finish" || current.inbox.steer.length !== 0 || current.inbox.followUp.length !== 0 || current.inbox.writes.length !== 0 @@ -427,13 +322,11 @@ async function finishRun( return { kind: "return", result: { kind: "continue" } as const }; } if (followUpId !== undefined && followUpMessage !== undefined) { - const nextState: RunState = { - ...current, - phase: { - kind: "checkpoint", - continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, - triggerEntryId: followUpId, - }, + const nextState: RunCheckpointOperation = { + ...runScopeOf(current), + at: "run.checkpoint", + continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, + triggerEntryId: followUpId, }; const entry: NewEntry = { id: followUpId, @@ -443,16 +336,9 @@ async function finishRun( }; return { kind: "commit", - writes: [ - insertEntry(entry), - setValue(branchTip(lane.name), followUpId), - setValue(operationStateValue(drive.operationId), nextState), - ], - next: { - ...state, - tipId: followUpId, - operation: { meta: operation.meta, state: nextState }, - }, + writes: [insertEntry(entry), setValue(branchTip(lane.name), followUpId)], + operationState: nextState, + lane: { tipId: followUpId }, materialize: () => ({ kind: "continue" }) as const, events: (commit) => lifecycleEvents( @@ -464,8 +350,8 @@ async function finishRun( } if (state.tipId === null) throw new SessionInvariantError("Completed run has no tip"); - const finalId = current.phase.continuation.includeFinalAssistant ? current.latestAssistantEntryId : null; - if (current.phase.continuation.includeFinalAssistant && finalId === null) { + const finalId = current.continuation.includeFinalAssistant ? current.latestAssistantEntryId : null; + if (current.continuation.includeFinalAssistant && finalId === null) { throw new SessionInvariantError("Completed run is missing its final assistant"); } let finalEntry: { id: string; message: SettledAssistantMessage } | undefined; @@ -499,16 +385,9 @@ async function finishRun( }; const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); return { - kind: "commit", - writes: [ - ...cleanup, - setValue(laneLastResultValue(lane.name), lastResult), - setValue(laneStateValue(lane.name), { - currentOperationId: null, - pendingNextRun: state.pendingNextRun, - }), - ], - next: { ...state, lastResult, operation: null }, + kind: "finish", + writes: cleanup, + lastResult, materialize: () => ({ kind: "settled", outcome }) as const, events: () => [ { @@ -524,85 +403,63 @@ async function finishRun( }, drive.context, ); + return result ?? { kind: "continue" }; } /** Advance one durable run checkpoint by one visible transition. */ export async function runCheckpoint( lane: Lane, drive: Drive, - run: RunState, - phase: CheckpointPhase, + run: RunCheckpointOperation, ): Promise { - if (run.phase.kind !== "checkpoint" || !sameCheckpoint(run.phase, phase)) { - throw new SessionInvariantError("runCheckpoint requires its current checkpoint phase"); + if (run.skipInboxOnce !== true && run.inbox.writes.length !== 0) { + return applyPendingWrites(lane, drive, run); } - if (run.control.status === "cancel_requested") return { kind: "continue" }; - if (phase.skipInboxOnce !== true && run.inbox.writes.length !== 0) { - return applyPendingWrites(lane, drive, phase); + if (run.skipInboxOnce !== true && run.inbox.steer.length !== 0) { + return consumeQueuedMessages(lane, drive, run, "steer"); } - if (phase.skipInboxOnce !== true && run.inbox.steer.length !== 0) { - return consumeQueuedMessages(lane, drive, phase, "steer"); - } - if (phase.continuation.kind === "need_assistant") { + if (run.continuation.kind === "need_assistant") { + const overflowRecoveryUsed = run.continuation.overflowRecoveryUsed; const config = lane.readConfig(); const retryPolicy = config.retryPolicy.enabled ? { maxAttempts: config.retryPolicy.maxRetries + 1, baseDelayMs: config.retryPolicy.baseDelayMs } : { maxAttempts: 1, baseDelayMs: config.retryPolicy.baseDelayMs }; const stepId = lane.session.idGenerator.next(); - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Generation start reached a non-run operation"); - } - const current = operation.state; - if (current.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } + const result = await lane.advanceOperation( + run, + (state, current) => { if ( - current.phase.kind !== "checkpoint" || - !sameCheckpoint(current.phase, phase) || - current.phase.continuation.kind !== "need_assistant" - ) { - throw new SessionInvariantError("Generation start lost its checkpoint boundary"); - } - if ( - current.phase.skipInboxOnce !== true && + current.skipInboxOnce !== true && (current.inbox.writes.length !== 0 || current.inbox.steer.length !== 0) ) { return { kind: "return", result: { kind: "continue" } as const }; } - const nextState: RunState = { - ...current, - phase: { - kind: "assistant", - generation: { - status: "ready", - context: { - stepId, - triggerEntryId: current.phase.triggerEntryId, - configuration: state.configuration, - streamOptions: config.streamOptions, - retryPolicy, - overflowRecoveryUsed: current.phase.continuation.overflowRecoveryUsed, - }, - nextAttempt: 1, - }, + const nextState: RunAssistantReadyOperation = { + ...runScopeOf(current), + at: "run.assistant.ready", + generationContext: { + stepId, + triggerEntryId: current.triggerEntryId, + configuration: state.configuration, + streamOptions: config.streamOptions, + retryPolicy, + overflowRecoveryUsed, }, + nextAttempt: 1, }; return { kind: "commit", - writes: [setValue(operationStateValue(drive.operationId), nextState)], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + writes: [], + operationState: nextState, materialize: () => ({ kind: "continue" }) as const, }; }, drive.context, ); + return result ?? { kind: "continue" }; } if (run.inbox.followUp.length !== 0) { - return consumeQueuedMessages(lane, drive, phase, "followUp"); + return consumeQueuedMessages(lane, drive, run, "followUp"); } - return finishRun(lane, drive, phase); + return finishRun(lane, drive, run); } diff --git a/packages/agent/src/harness/runtime/drive/deferred.ts b/packages/agent/src/harness/runtime/drive/deferred.ts index c2cacaf4b..79e16aacb 100644 --- a/packages/agent/src/harness/runtime/drive/deferred.ts +++ b/packages/agent/src/harness/runtime/drive/deferred.ts @@ -5,55 +5,31 @@ import { type DeferredHandle, type Model, } from "@earendil-works/pi-ai"; -import type { HarnessEvent } from "../../agent-harness.ts"; import { withAbortSignal } from "../../context.ts"; import { AbortRequested } from "../../execution/effect-gate.ts"; import { applyStreamOptionsPatch } from "../../hooks.ts"; -import { insertEntry, insertUsage } from "../../session/commit.ts"; import { SessionInvariantError } from "../../session/session.ts"; -import type { - Deferred, - MessageEntry, - NewEntry, - OperationError, - RunState, - SettledAssistantMessage, - ToolCall, - UsageRow, -} from "../../session/types.ts"; import { - branchTip, - deleteList, - operationState as operationStateValue, - pendingAssistantFrames, - setValue, -} from "../../session/values.ts"; + type LaneConfiguration, + type OperationError, + type OperationState, + type RunDeferredEffectPendingOperation, + type RunDeferredSuspendedOperation, + type RunScope, + runScopeOf, + type SettledAssistantMessage, +} from "../../session/types.ts"; +import { deleteList, pendingAssistantFrames } from "../../session/values.ts"; import type { AgentHarnessStreamOptions } from "../../types.ts"; import type { Lane } from "../lane.ts"; import { openFrameProgress } from "../progress.ts"; -import type { Drive, LaneCommand, LostOwnership, ProcedureResult } from "../types.ts"; +import type { Drive, ProcedureResult } from "../types.ts"; +import { settleResponse } from "./response.ts"; -type LocalResult = { kind: "committed" } | { kind: "state_changed" } | LostOwnership; -type SourceResult = { kind: "value"; handle: DeferredHandle } | { kind: "state_changed" } | LostOwnership; - -function sameDeferred(current: Deferred, expected: Deferred): boolean { - if ( - current.status !== expected.status || - current.stepId !== expected.stepId || - current.sourceEntryId !== expected.sourceEntryId || - current.poll !== expected.poll - ) { - return false; - } - return ( - current.status === "suspended" || - (expected.status === "effect_pending" && - current.responseEntryId === expected.responseEntryId && - current.usageId === expected.usageId) - ); -} - -function configurationError(identity: Deferred["configuration"]["model"]): OperationError { +type DeferredLeaf = RunDeferredSuspendedOperation | RunDeferredEffectPendingOperation; +/** Deferred effect payload without the run-wide scope fields. */ +type EffectPendingFields = Omit; +function configurationError(identity: LaneConfiguration["model"]): OperationError { return { code: "model_unavailable", message: "The configured model is unavailable in this process", @@ -61,31 +37,6 @@ function configurationError(identity: Deferred["configuration"]["model"]): Opera }; } -function providerError(message: SettledAssistantMessage): OperationError { - return { - code: "assistant_error", - message: message.errorMessage ?? `Deferred request ended with ${message.stopReason}`, - }; -} - -function normalizeError(message: SettledAssistantMessage, errorMessage: string): SettledAssistantMessage { - return { ...message, stopReason: "error", errorMessage }; -} - -function normalizeAborted(message: SettledAssistantMessage): SettledAssistantMessage { - return { - ...message, - stopReason: "aborted", - errorMessage: message.errorMessage ?? "Deferred request was cancelled", - }; -} - -function uuidV7Timestamp(id: string): number { - const timestamp = Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16); - if (!Number.isSafeInteger(timestamp)) throw new SessionInvariantError(`Invalid reserved UUIDv7 ${id}`); - return timestamp; -} - function isUpdateEvent( event: AssistantMessageEvent, ): event is Exclude { @@ -95,23 +46,12 @@ function isUpdateEvent( async function readSourceHandle( lane: Lane, drive: Drive, - expected: Deferred, -): Promise { - return lane.commandDriveOwned( - drive, - async (state, reader): Promise> => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Deferred source reached a non-run operation"); - } - const run = operation.state; - if (run.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - if (run.phase.kind !== "deferred" || !sameDeferred(run.phase.deferred, expected)) { - throw new SessionInvariantError("Deferred source lost its durable boundary"); - } - const source = (await reader.getEntries([expected.sourceEntryId], drive.context)).get(expected.sourceEntryId); + deferred: DeferredLeaf, +): Promise { + return lane.advanceOperation( + deferred, + async (_state, _current, _meta, reader) => { + const source = (await reader.getEntries([deferred.sourceEntryId], drive.context)).get(deferred.sourceEntryId); if ( source?.type !== "message" || source.message.role !== "assistant" || @@ -119,105 +59,78 @@ async function readSourceHandle( source.message.deferred === undefined ) { throw new SessionInvariantError( - `Deferred source ${expected.sourceEntryId} is missing its assistant handle`, + `Deferred source ${deferred.sourceEntryId} is missing its assistant handle`, ); } const handle = source.message.deferred; - const identity = expected.configuration.model; + const identity = deferred.configuration.model; if ( handle.id.length === 0 || handle.provider !== identity.provider || handle.modelId !== identity.modelId || handle.api !== source.message.api ) { - throw new SessionInvariantError(`Deferred source ${expected.sourceEntryId} has an invalid handle`); + throw new SessionInvariantError(`Deferred source ${deferred.sourceEntryId} has an invalid handle`); } - return { kind: "return", result: { kind: "value", handle } as const }; + return { kind: "return", result: handle }; }, drive.context, ); } -function enterDeferredConfigurationFailure( +async function enterDeferredConfigurationFailure( lane: Lane, drive: Drive, - expected: Deferred, + deferred: DeferredLeaf, ): Promise { - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Deferred configuration failure reached a non-run operation"); - } - const run = operation.state; - if (run.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (run.phase.kind !== "deferred" || !sameDeferred(run.phase.deferred, expected)) { - throw new SessionInvariantError("Deferred configuration failure lost its durable boundary"); - } - const nextState: RunState = { - ...run, - phase: { - kind: "failure_drain", - error: configurationError(expected.configuration.model), - provenance: { kind: "configuration" }, - }, + const result = await lane.advanceOperation( + deferred, + (_state, current) => { + const nextState: OperationState = { + ...runScopeOf(current), + at: "run.failure_drain", + error: configurationError(deferred.configuration.model), + provenance: { kind: "configuration" }, }; return { kind: "commit", - writes: [ - ...(expected.status === "effect_pending" - ? [deleteList(pendingAssistantFrames(drive.operationId, expected.responseEntryId))] - : []), - setValue(operationStateValue(drive.operationId), nextState), - ], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + writes: + deferred.at === "run.deferred.effect_pending" + ? [deleteList(pendingAssistantFrames(drive.operationId, deferred.responseEntryId))] + : [], + operationState: nextState, materialize: () => ({ kind: "continue" }) as const, }; }, drive.context, ); + return result ?? { kind: "continue" }; } async function commitPollIntent( lane: Lane, drive: Drive, - expected: Deferred, - next: Extract, + deferred: DeferredLeaf, + next: EffectPendingFields, recovery: boolean, -): Promise { - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Deferred poll intent reached a non-run operation"); - } - const run = operation.state; - if (run.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "state_changed" } as const }; - } +): Promise { + return lane.advanceOperation( + deferred, + (_state, current) => { if (drive.deferredPermits === 0) { throw new SessionInvariantError("Deferred poll intent lost its pass-local permit"); } - if (run.phase.kind !== "deferred" || !sameDeferred(run.phase.deferred, expected)) { - throw new SessionInvariantError("Deferred poll intent lost its durable boundary"); - } - const nextState: RunState = { ...run, phase: { kind: "deferred", deferred: next } }; + const nextState: RunDeferredEffectPendingOperation = { ...runScopeOf(current), ...next }; return { kind: "commit", - writes: [ - ...(expected.status === "effect_pending" - ? [deleteList(pendingAssistantFrames(drive.operationId, expected.responseEntryId))] - : []), - setValue(operationStateValue(drive.operationId), nextState), - ], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + writes: + deferred.at === "run.deferred.effect_pending" + ? [deleteList(pendingAssistantFrames(drive.operationId, deferred.responseEntryId))] + : [], + operationState: nextState, materialize: () => { drive.deferredPermits--; - return { kind: "committed" } as const; + return nextState; }, events: () => [ { @@ -355,180 +268,14 @@ async function streamDeferredResponse( return final; } -async function settleDeferredResponse( - lane: Lane, - drive: Drive, - expected: Extract, - response: SettledAssistantMessage, - recovery: boolean, -): Promise { - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Deferred settlement reached a non-run operation"); - } - const run = operation.state; - if (run.phase.kind !== "deferred" || !sameDeferred(run.phase.deferred, expected)) { - throw new SessionInvariantError("Deferred settlement lost its effect-pending boundary"); - } - - let committed = response; - let nextPhase: RunState["phase"]; - if (run.control.status === "cancel_requested") { - committed = normalizeAborted(response); - nextPhase = { - kind: "checkpoint", - continuation: { kind: "may_finish", includeFinalAssistant: true }, - triggerEntryId: expected.responseEntryId, - }; - } else if (response.stopReason === "aborted") { - throw new SessionInvariantError("Deferred response is aborted while durable control is running"); - } else if (response.stopReason === "deferred") { - nextPhase = { - kind: "deferred", - deferred: { - status: "suspended", - stepId: expected.stepId, - sourceEntryId: expected.responseEntryId, - poll: expected.poll, - configuration: expected.configuration, - streamOptions: expected.streamOptions, - }, - }; - } else if (response.stopReason === "error") { - nextPhase = { - kind: "failure_drain", - error: providerError(response), - provenance: { kind: "response", entryId: expected.responseEntryId }, - }; - } else { - const calls = response.content.flatMap((content, sourceIndex) => - content.type === "toolCall" ? [{ content, sourceIndex }] : [], - ); - if (calls.length !== 0) { - const timestamp = uuidV7Timestamp(expected.responseEntryId); - const planned: ToolCall[] = calls.map(({ sourceIndex }) => ({ - status: "planned", - sourceIndex, - resultEntryId: lane.session.idGenerator.next(timestamp), - })); - nextPhase = { - kind: "tools", - batch: { - assistantEntryId: expected.responseEntryId, - configuration: expected.configuration, - turnId: `${expected.stepId}:poll:${expected.poll}`, - calls: planned, - }, - }; - } else if (response.stopReason === "toolUse") { - committed = normalizeError(response, "Provider reported tool use without any tool calls"); - nextPhase = { - kind: "failure_drain", - error: providerError(committed), - provenance: { kind: "response", entryId: expected.responseEntryId }, - }; - } else { - nextPhase = { - kind: "checkpoint", - continuation: { kind: "may_finish", includeFinalAssistant: true }, - triggerEntryId: expected.responseEntryId, - }; - } - } - - const nextState: RunState = { - ...run, - phase: nextPhase, - latestAssistantEntryId: expected.responseEntryId, - }; - const responseEntry: NewEntry = { - id: expected.responseEntryId, - parentId: state.tipId, - type: "message", - message: committed, - }; - const usageRow: Omit = { - id: expected.usageId, - usage: committed.usage, - entryId: expected.responseEntryId, - adjustment: false, - }; - return { - kind: "commit", - writes: [ - insertEntry(responseEntry), - insertUsage(usageRow), - setValue(branchTip(lane.name), expected.responseEntryId), - deleteList(pendingAssistantFrames(drive.operationId, expected.responseEntryId)), - setValue(operationStateValue(drive.operationId), nextState), - ], - next: { - ...state, - tipId: expected.responseEntryId, - operation: { meta: operation.meta, state: nextState }, - }, - materialize: () => ({ kind: "continue" }) as const, - events: (commit) => { - const entry: MessageEntry = { - ...responseEntry, - seq: commit.seqs[0]!, - timestamp: commit.timestamp, - }; - const events: HarnessEvent[] = [ - { type: "entry_added", lane: lane.name, entry, ...(recovery ? { recovery: true } : {}) }, - { - type: "usage", - lane: lane.name, - row: { ...usageRow, seq: commit.seqs[1]! }, - totals: commit.stats.usage, - }, - ]; - if (nextPhase.kind !== "tools") { - events.push({ - type: "turn_end", - lane: lane.name, - runId: drive.operationId, - turnId: `${expected.stepId}:poll:${expected.poll}`, - message: committed, - toolResults: [], - ...(recovery ? { recovery: true } : {}), - }); - } - if (nextPhase.kind === "deferred" && committed.deferred !== undefined) { - events.push({ - type: "run_suspend", - lane: lane.name, - runId: drive.operationId, - reason: "deferred", - deferred: committed.deferred, - ...(recovery ? { recovery: true } : {}), - }); - } - return events; - }, - }; - }, - drive.context, - ); -} - async function pollDeferred( lane: Lane, drive: Drive, - run: RunState, - expected: Deferred, + expected: DeferredLeaf, recovery: boolean, ): Promise { - if (run.phase.kind !== "deferred" || !sameDeferred(run.phase.deferred, expected)) { - throw new SessionInvariantError("Deferred procedure requires its current durable phase"); - } - if (run.control.status === "cancel_requested") return { kind: "continue" }; const source = await readSourceHandle(lane, drive, expected); - if (source.kind === "lost_ownership") return source; - if (source.kind === "state_changed") return { kind: "continue" }; + if (source === undefined) return { kind: "continue" }; if (drive.deferredPermits === 0) { return { kind: "waiting", @@ -536,7 +283,7 @@ async function pollDeferred( kind: "waiting", operationId: drive.operationId, reason: "deferred", - deferred: source.handle, + deferred: source, }, }; } @@ -552,7 +299,7 @@ async function pollDeferred( runId: drive.operationId, model, step: "deferred", - attempt: expected.status === "suspended" ? expected.poll + 1 : expected.poll, + attempt: expected.at === "run.deferred.suspended" ? expected.poll + 1 : expected.poll, streamOptions: baseOptions, }, drive.gate, @@ -564,12 +311,12 @@ async function pollDeferred( : applyStreamOptionsPatch(baseOptions, beforeRequest.streamOptions)), deferred: false, }; - const poll = expected.status === "suspended" ? expected.poll + 1 : expected.poll; + const poll = expected.at === "run.deferred.suspended" ? expected.poll + 1 : expected.poll; const at = Date.now(); const responseEntryId = lane.session.idGenerator.next(at); const usageId = lane.session.idGenerator.next(at); - const effectPending: Extract = { - status: "effect_pending", + const effectPending: EffectPendingFields = { + at: "run.deferred.effect_pending", stepId: expected.stepId, sourceEntryId: expected.sourceEntryId, poll, @@ -579,49 +326,45 @@ async function pollDeferred( streamOptions: expected.streamOptions, }; const intent = await commitPollIntent(lane, drive, expected, effectPending, recovery); - if (intent.kind === "lost_ownership") return intent; - if (intent.kind === "state_changed") return { kind: "continue" }; + if (intent === undefined) return { kind: "continue" }; const response = await streamDeferredResponse( lane, drive, model, - source.handle, + source, streamOptions, - responseEntryId, + intent.responseEntryId, recovery, ); - return settleDeferredResponse(lane, drive, effectPending, response, recovery); + return settleResponse(lane, drive, intent, response, recovery ? { recovery: true } : {}); } /** Poll one durably suspended deferred response when this pass carries a permit. */ export function runDeferredSuspended( lane: Lane, drive: Drive, - run: RunState, - deferred: Extract, + deferred: RunDeferredSuspendedOperation, ): Promise { - return pollDeferred(lane, drive, run, deferred, false); + return pollDeferred(lane, drive, deferred, false); } /** Replace one orphaned unknown-outcome poll under fresh ids when this pass carries a permit. */ export function recoverDeferredPoll( lane: Lane, drive: Drive, - run: RunState, - deferred: Extract, + deferred: RunDeferredEffectPendingOperation, ): Promise { - return pollDeferred(lane, drive, run, deferred, true); + return pollDeferred(lane, drive, deferred, true); } /** Advance or report the wait for one deferred run phase. */ export function runDeferred( lane: Lane, drive: Drive, - run: RunState, - deferred: Deferred, + deferred: RunDeferredSuspendedOperation | RunDeferredEffectPendingOperation, ): Promise { - return deferred.status === "suspended" - ? runDeferredSuspended(lane, drive, run, deferred) - : recoverDeferredPoll(lane, drive, run, deferred); + return deferred.at === "run.deferred.suspended" + ? runDeferredSuspended(lane, drive, deferred) + : recoverDeferredPoll(lane, drive, deferred); } diff --git a/packages/agent/src/harness/runtime/drive/generation.ts b/packages/agent/src/harness/runtime/drive/generation.ts index 2ebe7294c..10b6f7675 100644 --- a/packages/agent/src/harness/runtime/drive/generation.ts +++ b/packages/agent/src/harness/runtime/drive/generation.ts @@ -2,44 +2,33 @@ import { type AssistantMessage, type AssistantMessageEvent, AssistantMessageFrameEncoder, - isRetryableAssistantError, type Tool, } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../../types.ts"; -import type { HarnessEvent } from "../../agent-harness.ts"; import { type Context, withAbortSignal } from "../../context.ts"; import { streamHarnessAssistant } from "../../execution/assistant.ts"; import { applyStreamOptionsPatch } from "../../hooks.ts"; -import { insertEntry, insertUsage } from "../../session/commit.ts"; import { buildSessionContext } from "../../session/context.ts"; import { SessionInvariantError } from "../../session/session.ts"; -import type { - Entry, - Generation, - GenerationContext, - JsonValue, - MessageEntry, - NewEntry, - OperationError, - RunState, - SettledAssistantMessage, - ToolCall, - UsageRow, -} from "../../session/types.ts"; import { - branchTip, - deleteList, - operationState as operationStateValue, - pendingAssistantFrames, - setValue, -} from "../../session/values.ts"; + type JsonValue, + type OperationError, + type OperationState, + type RunAssistantEffectPendingOperation, + type RunAssistantReadyOperation, + type RunAssistantRetryWaitOperation, + type RunScope, + runScopeOf, + type SettledAssistantMessage, +} from "../../session/types.ts"; import type { AgentHarnessStreamOptions } from "../../types.ts"; import type { Lane } from "../lane.ts"; import { openFrameProgress } from "../progress.ts"; -import type { Drive, LaneCommand, LostOwnership, ProcedureResult } from "../types.ts"; +import type { Drive, ProcedureResult } from "../types.ts"; +import { settleResponse } from "./response.ts"; -type LocalResult = { kind: "committed" } | { kind: "state_changed" } | LostOwnership; -type ReadResult = { kind: "value"; value: T } | { kind: "state_changed" } | LostOwnership; +/** Assistant effect-pending payload without the run-wide scope fields. */ +type AssistantEffectPending = Omit; const ZERO_USAGE = { input: 0, @@ -50,20 +39,6 @@ const ZERO_USAGE = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; -function sameReadyGeneration(current: Generation, expected: Extract): boolean { - return ( - current.status === "ready" && - current.context.stepId === expected.context.stepId && - current.nextAttempt === expected.nextAttempt - ); -} - -function uuidV7Timestamp(id: string): number { - const timestamp = Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16); - if (!Number.isSafeInteger(timestamp)) throw new SessionInvariantError(`Invalid reserved UUIDv7 ${id}`); - return timestamp; -} - function configurationError( code: "model_unavailable" | "configured_tools_unavailable", details: JsonValue, @@ -78,35 +53,6 @@ function configurationError( }; } -function providerError(message: SettledAssistantMessage): OperationError { - return { - code: "assistant_error", - message: message.errorMessage ?? `Assistant request ended with ${message.stopReason}`, - }; -} - -function normalizeError(message: SettledAssistantMessage, errorMessage: string): SettledAssistantMessage { - return { ...message, stopReason: "error", errorMessage }; -} - -function normalizeAborted(message: SettledAssistantMessage): SettledAssistantMessage { - return { - ...message, - stopReason: "aborted", - errorMessage: message.errorMessage ?? "Assistant request was cancelled", - }; -} - -function retryDelay(baseDelayMs: number, attempt: number): number { - const delay = baseDelayMs * 2 ** Math.max(0, attempt - 1); - return Number.isSafeInteger(delay) ? delay : Number.MAX_SAFE_INTEGER; -} - -function saturatingAdd(left: number, right: number): number { - const sum = left + right; - return Number.isSafeInteger(sum) ? sum : Number.MAX_SAFE_INTEGER; -} - function waitUntil(notBefore: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { let timer: ReturnType | undefined; @@ -133,30 +79,6 @@ function waitUntil(notBefore: number, signal: AbortSignal): Promise { }); } -function sameRetryWait(current: Generation, expected: Extract): boolean { - return ( - current.status === "retry_wait" && - current.context.stepId === expected.context.stepId && - current.nextAttempt === expected.nextAttempt && - current.notBefore === expected.notBefore - ); -} - -function deferredHandleIsValid( - message: SettledAssistantMessage, - identity: GenerationContext["configuration"]["model"], -): boolean { - const handle = message.deferred; - return ( - message.stopReason === "deferred" && - handle !== undefined && - handle.id.length !== 0 && - handle.provider === identity.provider && - handle.modelId === identity.modelId && - handle.api === message.api - ); -} - async function resolveSystemPrompt( lane: Lane, context: Context, @@ -172,24 +94,11 @@ async function resolveSystemPrompt( async function readRequestMessages( lane: Lane, drive: Drive, - generation: Extract, -): Promise> { - const path = await lane.commandDriveOwned>( - drive, - async (state, reader): Promise>> => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Generation context reached a non-run operation"); - } - if (operation.state.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - if ( - operation.state.phase.kind !== "assistant" || - !sameReadyGeneration(operation.state.phase.generation, generation) - ) { - throw new SessionInvariantError("Generation context lost its ready boundary"); - } + generation: RunAssistantReadyOperation, +): Promise { + const path = await lane.advanceOperation( + generation, + async (state, _current, _meta, reader) => { if (state.tipId === null) throw new SessionInvariantError("Assistant generation has no Branch tip"); const entries = ( await reader.scanBranch( @@ -197,55 +106,38 @@ async function readRequestMessages( drive.context, ) ).reverse(); - return { kind: "return", result: { kind: "value", value: entries } as const }; + return { kind: "return", result: entries }; }, drive.context, ); - if (path.kind !== "value") return path; - return { - kind: "value", - value: await buildSessionContext( - path.value, - { entryProjectors: lane.readConfig().entryProjectors }, - drive.context, - ), - }; + return path === undefined + ? undefined + : buildSessionContext(path, { entryProjectors: lane.readConfig().entryProjectors }, drive.context); } async function commitGenerationIntent( lane: Lane, drive: Drive, - expected: Extract, - generation: Extract, -): Promise { - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Generation intent reached a non-run operation"); - } - const run = operation.state; - if (run.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - if (run.phase.kind !== "assistant" || !sameReadyGeneration(run.phase.generation, expected)) { - throw new SessionInvariantError("Generation intent lost its ready boundary"); - } - const nextState: RunState = { ...run, phase: { kind: "assistant", generation } }; + ready: RunAssistantReadyOperation, + pending: AssistantEffectPending, +): Promise { + return lane.advanceOperation( + ready, + (_state, current) => { + const nextState: RunAssistantEffectPendingOperation = { ...runScopeOf(current), ...pending }; return { kind: "commit", - writes: [setValue(operationStateValue(drive.operationId), nextState)], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, - materialize: () => ({ kind: "committed" }) as const, + writes: [], + operationState: nextState, + materialize: () => nextState, events: () => - expected.nextAttempt === 1 + ready.nextAttempt === 1 ? [ { type: "turn_start", lane: lane.name, runId: drive.operationId, - turnId: expected.context.stepId, + turnId: ready.generationContext.stepId, }, ] : [], @@ -256,293 +148,39 @@ async function commitGenerationIntent( } /** Commit a non-retryable request-configuration failure before reserving response ids. */ -export function enterConfigurationFailure( +export async function enterConfigurationFailure( lane: Lane, drive: Drive, - expected: Extract, + ready: RunAssistantReadyOperation, error: OperationError, ): Promise { - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Configuration failure reached a non-run operation"); - } - const run = operation.state; - if (run.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (run.phase.kind !== "assistant" || !sameReadyGeneration(run.phase.generation, expected)) { - throw new SessionInvariantError("Configuration failure lost its ready boundary"); - } - const nextState: RunState = { - ...run, - phase: { kind: "failure_drain", error, provenance: { kind: "configuration" } }, + const result = await lane.advanceOperation( + ready, + (_state, current) => { + const nextState: OperationState = { + ...runScopeOf(current), + at: "run.failure_drain", + error, + provenance: { kind: "configuration" }, }; return { kind: "commit", - writes: [setValue(operationStateValue(drive.operationId), nextState)], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + writes: [], + operationState: nextState, materialize: () => ({ kind: "continue" }) as const, }; }, drive.context, ); -} - -interface SettlementIdentity { - responseEntryId: string; - usageId: string; - stepId: string; -} - -/** Classify and atomically settle one complete assistant response under its reserved ids. */ -export function settleAssistant( - lane: Lane, - drive: Drive, - identity: SettlementIdentity, - response: SettledAssistantMessage, - options: { recovery?: true } = {}, -): Promise { - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Assistant settlement reached a non-run operation"); - } - const run = operation.state; - if (run.phase.kind !== "assistant" || run.phase.generation.status !== "effect_pending") { - throw new SessionInvariantError("Assistant settlement lost its effect-pending boundary"); - } - const generation = run.phase.generation; - if ( - generation.context.stepId !== identity.stepId || - generation.responseEntryId !== identity.responseEntryId || - generation.usageId !== identity.usageId - ) { - throw new SessionInvariantError("Assistant settlement ids contradict the durable request intent"); - } - - const modelIdentity = generation.context.configuration.model; - let committed = response; - let nextPhase: RunState["phase"]; - if (run.control.status === "cancel_requested") { - committed = normalizeAborted(response); - nextPhase = { - kind: "checkpoint", - continuation: { kind: "may_finish", includeFinalAssistant: true }, - triggerEntryId: identity.responseEntryId, - }; - } else if (response.stopReason === "aborted") { - throw new SessionInvariantError("Assistant response is aborted while durable control is running"); - } else if (response.stopReason === "deferred") { - if (deferredHandleIsValid(response, modelIdentity)) { - nextPhase = { - kind: "deferred", - deferred: { - status: "suspended", - stepId: generation.context.stepId, - sourceEntryId: identity.responseEntryId, - poll: 0, - configuration: generation.context.configuration, - streamOptions: generation.context.streamOptions, - }, - }; - } else { - committed = normalizeError(response, "Provider returned an invalid deferred handle"); - nextPhase = { - kind: "failure_drain", - error: providerError(committed), - provenance: { kind: "response", entryId: identity.responseEntryId }, - }; - } - } else if ( - response.stopReason === "error" && - (options.recovery === true || isRetryableAssistantError(response)) && - generation.attempt < generation.context.retryPolicy.maxAttempts - ) { - const delayMs = retryDelay(generation.context.retryPolicy.baseDelayMs, generation.attempt); - nextPhase = { - kind: "assistant", - generation: { - status: "retry_wait", - context: generation.context, - nextAttempt: generation.attempt + 1, - notBefore: saturatingAdd(Date.now(), delayMs), - errorMessage: response.errorMessage ?? "Assistant request failed", - }, - }; - } else if (response.stopReason === "error") { - nextPhase = { - kind: "failure_drain", - error: providerError(response), - provenance: { kind: "response", entryId: identity.responseEntryId }, - }; - } else { - const calls = response.content.flatMap((content, sourceIndex) => - content.type === "toolCall" ? [{ content, sourceIndex }] : [], - ); - if (calls.length !== 0) { - const timestamp = uuidV7Timestamp(identity.responseEntryId); - const planned: ToolCall[] = calls.map(({ sourceIndex }) => ({ - status: "planned", - sourceIndex, - resultEntryId: lane.session.idGenerator.next(timestamp), - })); - nextPhase = { - kind: "tools", - batch: { - assistantEntryId: identity.responseEntryId, - configuration: generation.context.configuration, - turnId: generation.context.stepId, - calls: planned, - }, - }; - } else if (response.stopReason === "toolUse") { - committed = normalizeError(response, "Provider reported tool use without any tool calls"); - nextPhase = { - kind: "failure_drain", - error: providerError(committed), - provenance: { kind: "response", entryId: identity.responseEntryId }, - }; - } else { - nextPhase = { - kind: "checkpoint", - continuation: { kind: "may_finish", includeFinalAssistant: true }, - triggerEntryId: identity.responseEntryId, - }; - } - } - - const nextState: RunState = { - ...run, - phase: nextPhase, - latestAssistantEntryId: identity.responseEntryId, - }; - const responseEntry: NewEntry = { - id: identity.responseEntryId, - parentId: state.tipId, - type: "message", - message: committed, - }; - const usageRow: Omit = { - id: identity.usageId, - usage: committed.usage, - entryId: identity.responseEntryId, - adjustment: false, - }; - return { - kind: "commit", - writes: [ - insertEntry(responseEntry), - insertUsage(usageRow), - setValue(branchTip(lane.name), identity.responseEntryId), - deleteList(pendingAssistantFrames(drive.operationId, identity.responseEntryId)), - setValue(operationStateValue(drive.operationId), nextState), - ], - next: { - ...state, - tipId: identity.responseEntryId, - operation: { meta: operation.meta, state: nextState }, - }, - materialize: () => ({ kind: "continue" }) as const, - events: (commit) => { - const entry: MessageEntry = { - ...responseEntry, - seq: commit.seqs[0]!, - timestamp: commit.timestamp, - }; - const events: HarnessEvent[] = [ - { type: "entry_added", lane: lane.name, entry, ...options }, - { - type: "usage", - lane: lane.name, - row: { ...usageRow, seq: commit.seqs[1]! }, - totals: commit.stats.usage, - }, - ]; - if ( - options.recovery !== true && - generation.attempt > 1 && - !(nextPhase.kind === "assistant" && nextPhase.generation.status === "retry_wait") - ) { - const success = committed.stopReason !== "error" && committed.stopReason !== "aborted"; - events.push({ - type: "retry_end", - lane: lane.name, - runId: drive.operationId, - step: identity.stepId, - attempt: generation.attempt, - success, - ...(success - ? {} - : { - finalError: - committed.errorMessage ?? `Assistant request ended with ${committed.stopReason}`, - }), - }); - } - if ( - options.recovery !== true && - nextPhase.kind === "assistant" && - nextPhase.generation.status === "retry_wait" - ) { - events.push({ - type: "retry_scheduled", - lane: lane.name, - runId: drive.operationId, - step: identity.stepId, - attempt: nextPhase.generation.nextAttempt, - maxAttempts: nextPhase.generation.context.retryPolicy.maxAttempts, - delayMs: retryDelay(generation.context.retryPolicy.baseDelayMs, generation.attempt), - errorMessage: nextPhase.generation.errorMessage, - }); - } - if ( - options.recovery !== true && - nextPhase.kind !== "tools" && - !(nextPhase.kind === "assistant" && nextPhase.generation.status === "retry_wait") - ) { - events.push({ - type: "turn_end", - lane: lane.name, - runId: drive.operationId, - turnId: identity.stepId, - message: committed, - toolResults: [], - ...options, - }); - } - if (options.recovery !== true && nextPhase.kind === "deferred" && committed.deferred !== undefined) { - events.push({ - type: "run_suspend", - lane: lane.name, - runId: drive.operationId, - reason: "deferred", - deferred: committed.deferred, - }); - } - return events; - }, - }; - }, - drive.context, - ); + return result ?? { kind: "continue" }; } /** Advance one durable assistant retry wait according to this pass's local wait policy. */ export async function runRetryWait( lane: Lane, drive: Drive, - run: RunState, - generation: Extract, + generation: RunAssistantRetryWaitOperation, ): Promise { - if (run.phase.kind !== "assistant" || !sameRetryWait(run.phase.generation, generation)) { - throw new SessionInvariantError("runRetryWait requires its current retry wait"); - } - if (run.control.status === "cancel_requested") return { kind: "continue" }; if (Date.now() < generation.notBefore) { if (!drive.waitForRetry) { return { @@ -558,42 +196,26 @@ export async function runRetryWait( await drive.gate.admit(() => waitUntil(generation.notBefore, drive.gate.signal)); } - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if (operation === null || operation.state.kind !== "run") { - throw new SessionInvariantError("Retry wait reached a non-run operation"); - } - const current = operation.state; - if (current.control.status === "cancel_requested") { - return { kind: "return", result: { kind: "continue" } as const }; - } - if (current.phase.kind !== "assistant" || !sameRetryWait(current.phase.generation, generation)) { - throw new SessionInvariantError("Retry wait lost its durable boundary"); - } - const nextState: RunState = { - ...current, - phase: { - kind: "assistant", - generation: { - status: "ready", - context: generation.context, - nextAttempt: generation.nextAttempt, - }, - }, + const result = await lane.advanceOperation( + generation, + (_state, current) => { + const nextState: RunAssistantReadyOperation = { + ...runScopeOf(current), + at: "run.assistant.ready", + generationContext: generation.generationContext, + nextAttempt: generation.nextAttempt, }; return { kind: "commit", - writes: [setValue(operationStateValue(drive.operationId), nextState)], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + writes: [], + operationState: nextState, materialize: () => ({ kind: "continue" }) as const, events: () => [ { type: "retry_start", lane: lane.name, runId: drive.operationId, - step: generation.context.stepId, + step: generation.generationContext.stepId, attempt: generation.nextAttempt, }, ], @@ -601,32 +223,27 @@ export async function runRetryWait( }, drive.context, ); + return result ?? { kind: "continue" }; } /** Execute one ready assistant generation or advance its durable retry wait. */ export async function runGeneration( lane: Lane, drive: Drive, - run: RunState, - generation: Generation, + generation: RunAssistantReadyOperation | RunAssistantRetryWaitOperation, ): Promise { - if (generation.status === "retry_wait") return runRetryWait(lane, drive, run, generation); - if (generation.status !== "ready") { - throw new SessionInvariantError("runGeneration requires a ready generation or retry wait"); - } - if (run.phase.kind !== "assistant" || !sameReadyGeneration(run.phase.generation, generation)) { - throw new SessionInvariantError("runGeneration requires its current ready phase"); - } - if (run.control.status === "cancel_requested") return { kind: "continue" }; + if (generation.at === "run.assistant.retry_wait") return runRetryWait(lane, drive, generation); - const identity = generation.context.configuration.model; + const identity = generation.generationContext.configuration.model; const model = lane.models.getModel(identity.provider, identity.modelId); if (model === undefined) { return enterConfigurationFailure(lane, drive, generation, configurationError("model_unavailable", identity)); } const config = lane.readConfig(); const toolsByName = new Map(config.tools.map((tool) => [tool.name, tool])); - const missingTools = generation.context.configuration.activeToolNames.filter((name) => !toolsByName.has(name)); + const missingTools = generation.generationContext.configuration.activeToolNames.filter( + (name) => !toolsByName.has(name), + ); if (missingTools.length !== 0) { return enterConfigurationFailure( lane, @@ -635,7 +252,7 @@ export async function runGeneration( configurationError("configured_tools_unavailable", { tools: missingTools }), ); } - const tools: Tool[] = generation.context.configuration.activeToolNames.map((name) => { + const tools: Tool[] = generation.generationContext.configuration.activeToolNames.map((name) => { const tool = toolsByName.get(name); if (tool === undefined) throw new SessionInvariantError(`Configured tool ${name} disappeared during resolution`); return { @@ -647,8 +264,7 @@ export async function runGeneration( }); const messages = await readRequestMessages(lane, drive, generation); - if (messages.kind === "lost_ownership") return messages; - if (messages.kind === "state_changed") return { kind: "continue" }; + if (messages === undefined) return { kind: "continue" }; const systemPrompt = await resolveSystemPrompt(lane, drive.context); const beforeRequest = await lane.hooks.runWithGate( "before_request", @@ -658,21 +274,21 @@ export async function runGeneration( model, step: "assistant", attempt: generation.nextAttempt, - streamOptions: generation.context.streamOptions, + streamOptions: generation.generationContext.streamOptions, }, drive.gate, drive.context, ); const streamOptions: AgentHarnessStreamOptions = beforeRequest?.streamOptions === undefined - ? generation.context.streamOptions - : applyStreamOptionsPatch(generation.context.streamOptions, beforeRequest.streamOptions); + ? generation.generationContext.streamOptions + : applyStreamOptionsPatch(generation.generationContext.streamOptions, beforeRequest.streamOptions); const at = Date.now(); const responseEntryId = lane.session.idGenerator.next(at); const usageId = lane.session.idGenerator.next(at); - const effectPending: Extract = { - status: "effect_pending", - context: generation.context, + const effectPending: AssistantEffectPending = { + at: "run.assistant.effect_pending", + generationContext: generation.generationContext, attempt: generation.nextAttempt, responseEntryId, usageId, @@ -680,10 +296,9 @@ export async function runGeneration( contextWindow: model.contextWindow, }; const intent = await commitGenerationIntent(lane, drive, generation, effectPending); - if (intent.kind === "lost_ownership") return intent; - if (intent.kind === "state_changed") return { kind: "continue" }; + if (intent === undefined) return { kind: "continue" }; - const progress = openFrameProgress(lane, drive, responseEntryId); + const progress = openFrameProgress(lane, drive, intent.responseEntryId); const frameEncoder = new AssistantMessageFrameEncoder(); let drained = false; const drainProgress = async () => { @@ -695,12 +310,12 @@ export async function runGeneration( let response: SettledAssistantMessage; try { response = await streamHarnessAssistant( - messages.value, + messages, { model, systemPrompt, tools, - thinkingLevel: generation.context.configuration.thinkingLevel, + thinkingLevel: generation.generationContext.configuration.thinkingLevel, streamOptions, transformContext: async (requestContext, context) => { const result = await lane.hooks.runWithGate( @@ -792,11 +407,11 @@ export async function runGeneration( await drainProgress(); } - return settleAssistant(lane, drive, { responseEntryId, usageId, stepId: generation.context.stepId }, response); + return settleResponse(lane, drive, intent, response); } export function interruptedAssistantMessage( - generation: Extract, + generation: RunAssistantEffectPendingOperation, partial: AssistantMessage | undefined, ): SettledAssistantMessage { const warning = @@ -806,8 +421,8 @@ export function interruptedAssistantMessage( role: "assistant", content: [], api: "unknown", - provider: generation.context.configuration.model.provider, - model: generation.context.configuration.model.modelId, + provider: generation.generationContext.configuration.model.provider, + model: generation.generationContext.configuration.model.modelId, usage: ZERO_USAGE, stopReason: "error", errorMessage: warning, diff --git a/packages/agent/src/harness/runtime/drive/recovery.ts b/packages/agent/src/harness/runtime/drive/recovery.ts index 3415613fc..49f245133 100644 --- a/packages/agent/src/harness/runtime/drive/recovery.ts +++ b/packages/agent/src/harness/runtime/drive/recovery.ts @@ -1,39 +1,21 @@ import { type AssistantMessageFrame, reduceAssistantMessageFrames } from "@earendil-works/pi-ai"; -import { SessionInvariantError } from "../../session/session.ts"; -import type { Generation, RunState } from "../../session/types.ts"; +import type { RunAssistantEffectPendingOperation } from "../../session/types.ts"; import { pendingAssistantFrames } from "../../session/values.ts"; import type { Lane } from "../lane.ts"; import type { Drive, ProcedureResult } from "../types.ts"; -import { interruptedAssistantMessage, settleAssistant } from "./generation.ts"; +import { interruptedAssistantMessage } from "./generation.ts"; +import { settleResponse } from "./response.ts"; /** Settle an orphaned assistant request from its bounded committed frame prefix without another provider call. */ export async function recoverAssistantGeneration( lane: Lane, drive: Drive, - run: RunState, - generation: Extract, + generation: RunAssistantEffectPendingOperation, ): Promise { - if ( - run.phase.kind !== "assistant" || - run.phase.generation.status !== "effect_pending" || - run.phase.generation.responseEntryId !== generation.responseEntryId - ) { - throw new SessionInvariantError("Assistant recovery requires its current effect-pending generation"); - } - const read = await lane.commandDriveOwned<{ kind: "frames"; frames: AssistantMessageFrame[] }>( - drive, - async (state, reader) => { - const operation = state.operation; - if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "assistant" || - operation.state.phase.generation.status !== "effect_pending" || - operation.state.phase.generation.responseEntryId !== generation.responseEntryId - ) { - throw new SessionInvariantError("Assistant recovery lost its effect-pending generation"); - } - const frames: AssistantMessageFrame[] = []; + const frames = await lane.advanceOperation( + generation, + async (_state, _current, _meta, reader) => { + const result: AssistantMessageFrame[] = []; let cursor: { seq: number } | undefined; for (;;) { const page = await reader.readList( @@ -41,28 +23,16 @@ export async function recoverAssistantGeneration value)); + result.push(...page.map(({ value }) => value)); if (page.length < 1_000) break; cursor = { seq: page[page.length - 1]!.seq }; } - return { kind: "return", result: { kind: "frames", frames } as const }; + return { kind: "return", result }; }, drive.context, ); - if (read.kind === "lost_ownership") return read; - const frames = read.frames; - const current = lane.state.operation; - if (current?.meta.operationId !== drive.operationId || !lane.isDriveActive(drive)) { - return { kind: "lost_ownership" }; - } - if ( - current.state.kind !== "run" || - current.state.phase.kind !== "assistant" || - current.state.phase.generation.status !== "effect_pending" || - current.state.phase.generation.responseEntryId !== generation.responseEntryId - ) { - throw new SessionInvariantError("Assistant recovery lost its effect-pending generation"); - } + if (frames === undefined) return { kind: "continue" }; + const message = interruptedAssistantMessage(generation, reduceAssistantMessageFrames(frames)); await lane.emitBatch( [ @@ -84,15 +54,5 @@ export async function recoverAssistantGeneration( + lane: Lane, + drive: Drive, + intent: ResponseIntent, + response: SettledAssistantMessage, + options: { recovery?: true } = {}, +): Promise { + return lane.mutateOperation( + intent, + (state, current) => { + const source = current.at === "run.assistant.effect_pending" ? "assistant" : "deferred"; + const responseEntryId = intent.responseEntryId; + const configuration = + current.at === "run.assistant.effect_pending" + ? current.generationContext.configuration + : current.configuration; + const turnId = + current.at === "run.assistant.effect_pending" + ? current.generationContext.stepId + : `${current.stepId}:poll:${current.poll}`; + const scope = { ...runScopeOf(current), latestAssistantEntryId: responseEntryId }; + let committed = response; + let settled: OperationState; + + if (current.control.status === "cancel_requested") { + committed = normalizeAborted(source, response); + settled = { + ...scope, + at: "run.checkpoint", + continuation: { kind: "may_finish", includeFinalAssistant: true }, + triggerEntryId: responseEntryId, + }; + } else if (response.stopReason === "aborted") { + throw new SessionInvariantError( + `${source === "assistant" ? "Assistant" : "Deferred"} response is aborted while durable control is running`, + ); + } else if (response.stopReason === "deferred") { + if (current.at === "run.assistant.effect_pending") { + if (deferredHandleIsValid(response, current)) { + settled = { + ...scope, + at: "run.deferred.suspended", + stepId: current.generationContext.stepId, + sourceEntryId: responseEntryId, + poll: 0, + configuration, + streamOptions: current.generationContext.streamOptions, + }; + } else { + committed = normalizeError(response, "Provider returned an invalid deferred handle"); + settled = { + ...scope, + at: "run.failure_drain", + error: providerError(source, committed), + provenance: { kind: "response", entryId: responseEntryId }, + }; + } + } else { + settled = { + ...scope, + at: "run.deferred.suspended", + stepId: current.stepId, + sourceEntryId: responseEntryId, + poll: current.poll, + configuration, + streamOptions: current.streamOptions, + }; + } + } else if (response.stopReason === "error") { + if ( + current.at === "run.assistant.effect_pending" && + (options.recovery === true || isRetryableAssistantError(response)) && + current.attempt < current.generationContext.retryPolicy.maxAttempts + ) { + const delayMs = retryDelay(current.generationContext.retryPolicy.baseDelayMs, current.attempt); + settled = { + ...scope, + at: "run.assistant.retry_wait", + generationContext: current.generationContext, + nextAttempt: current.attempt + 1, + notBefore: saturatingAdd(Date.now(), delayMs), + errorMessage: response.errorMessage ?? "Assistant request failed", + }; + } else { + settled = { + ...scope, + at: "run.failure_drain", + error: providerError(source, response), + provenance: { kind: "response", entryId: responseEntryId }, + }; + } + } else { + const calls = response.content.flatMap((content, sourceIndex) => + content.type === "toolCall" ? [{ sourceIndex }] : [], + ); + if (calls.length !== 0) { + const timestamp = uuidV7Timestamp(responseEntryId); + const planned: ToolCall[] = calls.map(({ sourceIndex }) => ({ + status: "planned", + sourceIndex, + resultEntryId: lane.session.idGenerator.next(timestamp), + })); + settled = { + ...scope, + at: "run.tools", + batch: { assistantEntryId: responseEntryId, configuration, turnId, calls: planned }, + }; + } else if (response.stopReason === "toolUse") { + committed = normalizeError(response, "Provider reported tool use without any tool calls"); + settled = { + ...scope, + at: "run.failure_drain", + error: providerError(source, committed), + provenance: { kind: "response", entryId: responseEntryId }, + }; + } else { + settled = { + ...scope, + at: "run.checkpoint", + continuation: { kind: "may_finish", includeFinalAssistant: true }, + triggerEntryId: responseEntryId, + }; + } + } + + const responseEntry: NewEntry = { + id: responseEntryId, + parentId: state.tipId, + type: "message", + message: committed, + }; + const usageRow: Omit = { + id: intent.usageId, + usage: committed.usage, + entryId: responseEntryId, + adjustment: false, + }; + return { + kind: "commit", + writes: [ + insertEntry(responseEntry), + insertUsage(usageRow), + setValue(branchTip(lane.name), responseEntryId), + deleteList(pendingAssistantFrames(drive.operationId, responseEntryId)), + ], + operationState: settled, + lane: { tipId: responseEntryId }, + materialize: () => ({ kind: "continue" }) as const, + events: (commit) => { + const entry: MessageEntry = { + ...responseEntry, + seq: commit.seqs[0]!, + timestamp: commit.timestamp, + }; + const events: HarnessEvent[] = [ + { type: "entry_added", lane: lane.name, entry, ...options }, + { + type: "usage", + lane: lane.name, + row: { ...usageRow, seq: commit.seqs[1]! }, + totals: commit.stats.usage, + }, + ]; + if (current.at === "run.assistant.effect_pending") { + if (options.recovery !== true && current.attempt > 1 && settled.at !== "run.assistant.retry_wait") { + const success = committed.stopReason !== "error" && committed.stopReason !== "aborted"; + events.push({ + type: "retry_end", + lane: lane.name, + runId: drive.operationId, + step: turnId, + attempt: current.attempt, + success, + ...(success + ? {} + : { + finalError: + committed.errorMessage ?? `Assistant request ended with ${committed.stopReason}`, + }), + }); + } + if (options.recovery !== true && settled.at === "run.assistant.retry_wait") { + events.push({ + type: "retry_scheduled", + lane: lane.name, + runId: drive.operationId, + step: turnId, + attempt: settled.nextAttempt, + maxAttempts: settled.generationContext.retryPolicy.maxAttempts, + delayMs: retryDelay(current.generationContext.retryPolicy.baseDelayMs, current.attempt), + errorMessage: settled.errorMessage, + }); + } + if ( + options.recovery !== true && + settled.at !== "run.tools" && + settled.at !== "run.assistant.retry_wait" + ) { + events.push({ + type: "turn_end", + lane: lane.name, + runId: drive.operationId, + turnId, + message: committed, + toolResults: [], + }); + } + } else if (settled.at !== "run.tools") { + events.push({ + type: "turn_end", + lane: lane.name, + runId: drive.operationId, + turnId, + message: committed, + toolResults: [], + ...options, + }); + } + if ( + (current.at === "run.deferred.effect_pending" || options.recovery !== true) && + settled.at === "run.deferred.suspended" && + committed.deferred !== undefined + ) { + events.push({ + type: "run_suspend", + lane: lane.name, + runId: drive.operationId, + reason: "deferred", + deferred: committed.deferred, + ...(current.at === "run.deferred.effect_pending" ? options : {}), + }); + } + return events; + }, + }; + }, + drive.context, + ); +} diff --git a/packages/agent/src/harness/runtime/drive/terminal.ts b/packages/agent/src/harness/runtime/drive/terminal.ts index 363b506a9..0f4877940 100644 --- a/packages/agent/src/harness/runtime/drive/terminal.ts +++ b/packages/agent/src/harness/runtime/drive/terminal.ts @@ -2,15 +2,16 @@ import type { AgentMessage } from "../../../types.ts"; import type { TerminalOperationOutcome } from "../../agent-harness.ts"; import type { Context } from "../../context.ts"; import { SessionInvariantError } from "../../session/session.ts"; -import type { - BranchSummaryEntry, - CompactionEntry, - LaneLastResult, - MessageEntry, - OperationState, - SessionReader, - SettledAssistantMessage, - Write, +import { + type BranchSummaryEntry, + type CompactionEntry, + isRunOperationState, + type LaneLastResult, + type MessageEntry, + type OperationState, + type SessionReader, + type SettledAssistantMessage, + type Write, } from "../../session/types.ts"; import { deleteList, @@ -40,12 +41,12 @@ export async function operationCleanupWrites( ]); const pendingIds = new Set(); - if (state.kind === "run") { + if (isRunOperationState(state)) { for (const id of state.inbox.steer) pendingIds.add(id); for (const id of state.inbox.followUp) pendingIds.add(id); for (const id of state.inbox.writes) pendingIds.add(id); - if (state.phase.kind === "tools") { - for (const call of state.phase.batch.calls) { + if (state.at === "run.tools") { + for (const call of state.batch.calls) { if (call.status === "outcome_ready") pendingIds.add(call.resultEntryId); } } @@ -56,16 +57,8 @@ export async function operationCleanupWrites( } let frameDelete: Write | undefined; - if (state.kind === "run" && state.phase.kind === "assistant") { - const generation = state.phase.generation; - if (generation.status === "effect_pending") { - frameDelete = deleteList(pendingAssistantFrames(operationId, generation.responseEntryId)); - } - } else if (state.kind === "run" && state.phase.kind === "deferred") { - const deferred = state.phase.deferred; - if (deferred.status === "effect_pending") { - frameDelete = deleteList(pendingAssistantFrames(operationId, deferred.responseEntryId)); - } + if (state.at === "run.assistant.effect_pending" || state.at === "run.deferred.effect_pending") { + frameDelete = deleteList(pendingAssistantFrames(operationId, state.responseEntryId)); } return [ diff --git a/packages/agent/src/harness/runtime/drive/tools.ts b/packages/agent/src/harness/runtime/drive/tools.ts index f0fca742e..5ca4f8e0e 100644 --- a/packages/agent/src/harness/runtime/drive/tools.ts +++ b/packages/agent/src/harness/runtime/drive/tools.ts @@ -13,21 +13,23 @@ import { } from "../../execution/tools.ts"; import { insertEntry, insertUsage } from "../../session/commit.ts"; import { SessionInvariantError } from "../../session/session.ts"; -import type { - JsonValue, - MessageEntry, - NewEntry, - RunState, - ToolBatch, - ToolCall, - UsageRow, - Write, +import { + type JsonValue, + type MessageEntry, + type NewEntry, + type OperationState, + type RunCheckpointOperation, + type RunToolsOperation, + runScopeOf, + type ToolBatch, + type ToolCall, + type UsageRow, + type Write, } from "../../session/types.ts"; import { branchTip, deleteValue, laneConfig, - operationState as operationStateValue, operationToolArgs, operationToolArgsPrefix, operationToolMemo, @@ -39,10 +41,10 @@ import { import type { AgentHarnessTool, AgentHarnessToolInvocation } from "../../types.ts"; import type { Lane } from "../lane.ts"; import { openToolProgress } from "../progress.ts"; -import type { Drive, LaneState, LostOwnership, ProcedureResult } from "../types.ts"; +import type { Drive, LaneState, ProcedureResult } from "../types.ts"; -type LocalResult = { kind: "committed" } | { kind: "state_changed" } | LostOwnership; -type PlacementResult = { kind: "none" } | { kind: "committed"; complete: boolean } | LostOwnership; +type LocalResult = { kind: "committed" } | { kind: "state_changed" }; +type PlacementResult = { kind: "none" } | { kind: "committed"; complete: boolean }; type BatchSource = { kind: "source"; @@ -72,20 +74,16 @@ class ToolInvocationEnded extends Error { } } -function batchMatches(current: ToolBatch, expected: ToolBatch): boolean { - return current.assistantEntryId === expected.assistantEntryId && current.turnId === expected.turnId; -} - function currentBatch( lane: Lane, - drive: Drive, - expected: ToolBatch, -): { run: RunState; batch: ToolBatch } | undefined { +): { run: RunToolsOperation; batch: ToolBatch } | undefined { const operation = lane.state.operation; - if (operation?.meta.operationId !== drive.operationId || operation.state.kind !== "run") return undefined; - const run = operation.state; - if (run.phase.kind !== "tools" || !batchMatches(run.phase.batch, expected)) return undefined; - return { run, batch: run.phase.batch }; + if (operation?.state.at !== "run.tools") return undefined; + return { run: operation.state, batch: operation.state.batch }; +} + +function toolOperation(lane: Lane): RunToolsOperation { + return lane.state.operation!.state as RunToolsOperation; } function findCall(batch: ToolBatch, sourceIndex: number, resultEntryId: string): ToolCall | undefined { @@ -103,8 +101,8 @@ function replaceCall(batch: ToolBatch, replacement: ToolCall): ToolBatch { }; } -function withBatch(run: RunState, batch: ToolBatch): RunState { - return { ...run, phase: { kind: "tools", batch } }; +function withBatch(run: RunToolsOperation, batch: ToolBatch): RunToolsOperation { + return { ...runScopeOf(run), at: "run.tools", batch }; } function validateMemoName(name: string): void { @@ -128,37 +126,24 @@ async function readBatchSource( lane: Lane, drive: Drive, batch: ToolBatch, -): Promise { - return lane.commandDriveOwned( - drive, - async (state, reader) => { - const operation = state.operation; - if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) - ) { - throw new SessionInvariantError("Tool procedure lost its batch source"); +): Promise { + return lane.command(async (_state, reader) => { + const entry = (await reader.getEntries([batch.assistantEntryId], drive.context)).get(batch.assistantEntryId); + if (entry?.type !== "message" || entry.message.role !== "assistant") { + throw new SessionInvariantError("Tool batch assistant entry is invalid"); + } + const calls = new Map(); + for (const call of batch.calls) { + const block = entry.message.content[call.sourceIndex]; + if (block?.type !== "toolCall") { + throw new SessionInvariantError( + `Tool call source index ${call.sourceIndex} does not name a tool-call block`, + ); } - const entry = (await reader.getEntries([batch.assistantEntryId], drive.context)).get(batch.assistantEntryId); - if (entry?.type !== "message" || entry.message.role !== "assistant") { - throw new SessionInvariantError("Tool batch assistant entry is invalid"); - } - const calls = new Map(); - for (const call of batch.calls) { - const block = entry.message.content[call.sourceIndex]; - if (block?.type !== "toolCall") { - throw new SessionInvariantError( - `Tool call source index ${call.sourceIndex} does not name a tool-call block`, - ); - } - calls.set(call.sourceIndex, block); - } - return { kind: "return", result: { kind: "source", assistant: entry.message, calls } }; - }, - drive.context, - ); + calls.set(call.sourceIndex, block); + } + return { kind: "return", result: { kind: "source", assistant: entry.message, calls } }; + }, drive.context); } function invocationCapability( @@ -170,15 +155,8 @@ function invocationCapability( let active = true; const ownsEffect = (state: LaneState): boolean => { const operation = state.operation; - if ( - operation?.meta.operationId !== drive.operationId || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) - ) { - return false; - } - return findCall(operation.state.phase.batch, call.sourceIndex, call.resultEntryId)?.status === "effect_pending"; + if (operation?.state.at !== "run.tools") return false; + return findCall(operation.state.batch, call.sourceIndex, call.resultEntryId)?.status === "effect_pending"; }; const ended = (): ToolInvocationEnded => new ToolInvocationEnded(); return { @@ -190,23 +168,19 @@ function invocationCapability( validateMemoName(name); if (!active) return Promise.reject(ended()); return lane - .commandDriveOwned<{ kind: "value"; value: JsonValue | undefined } | { kind: "ended" }>( - drive, - async (state, reader) => { - if (!ownsEffect(state)) return { kind: "return", result: { kind: "ended" } as const }; - const stored = await reader.getValue( - operationToolMemo(drive.operationId, call.resultEntryId, name), - drive.context, - ); - return { - kind: "return", - result: { kind: "value", value: stored?.value } as const, - }; - }, - drive.context, - ) + .command<{ kind: "value"; value: JsonValue | undefined } | { kind: "ended" }>(async (state, reader) => { + if (!ownsEffect(state)) return { kind: "return", result: { kind: "ended" } as const }; + const stored = await reader.getValue( + operationToolMemo(drive.operationId, call.resultEntryId, name), + drive.context, + ); + return { + kind: "return", + result: { kind: "value", value: stored?.value } as const, + }; + }, drive.context) .then((result) => { - if (result.kind === "lost_ownership" || result.kind === "ended") throw ended(); + if (result.kind === "ended") throw ended(); return result.value; }); }, @@ -214,22 +188,18 @@ function invocationCapability( validateMemoName(name); if (!active) return Promise.reject(ended()); return lane - .commandDriveOwned<{ kind: "written" } | { kind: "ended" }>( - drive, - (state) => { - if (!ownsEffect(state)) return { kind: "return", result: { kind: "ended" } as const }; - const address = operationToolMemo(drive.operationId, call.resultEntryId, name); - return { - kind: "commit", - writes: [value === undefined ? deleteValue(address) : setValue(address, value)], - next: state, - materialize: () => ({ kind: "written" }) as const, - }; - }, - drive.context, - ) + .command<{ kind: "written" } | { kind: "ended" }>((state) => { + if (!ownsEffect(state)) return { kind: "return", result: { kind: "ended" } as const }; + const address = operationToolMemo(drive.operationId, call.resultEntryId, name); + return { + kind: "commit", + writes: [value === undefined ? deleteValue(address) : setValue(address, value)], + next: state, + materialize: () => ({ kind: "written" }) as const, + }; + }, drive.context) .then((result) => { - if (result.kind === "lost_ownership" || result.kind === "ended") throw ended(); + if (result.kind === "ended") throw ended(); }); }, }, @@ -288,20 +258,10 @@ async function commitIntent( args: Record, replay: "never" | "safe", ): Promise { - return lane.commandDriveOwned( - drive, - (state) => { - const operation = state.operation; - if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) - ) { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - const run = operation.state; - const currentBatchState = operation.state.phase.batch; + return lane.mutateOperation( + toolOperation(lane), + (_state, run) => { + const currentBatchState = run.batch; if (run.control.status === "cancel_requested") { return { kind: "return", result: { kind: "state_changed" } as const }; } @@ -318,11 +278,8 @@ async function commitIntent( const nextRun = withBatch(run, replaceCall(currentBatchState, effectPending)); return { kind: "commit", - writes: [ - setValue(operationToolArgs(drive.operationId, batch.turnId, current.sourceIndex), args), - setValue(operationStateValue(drive.operationId), nextRun), - ], - next: { ...state, operation: { meta: operation.meta, state: nextRun } }, + writes: [setValue(operationToolArgs(drive.operationId, batch.turnId, current.sourceIndex), args)], + operationState: nextRun, materialize: () => ({ kind: "committed" }) as const, }; }, @@ -333,25 +290,14 @@ async function commitIntent( async function stageOutcome( lane: Lane, drive: Drive, - batch: ToolBatch, call: ToolCall, message: ToolResultMessage, terminate: boolean, ): Promise { - return lane.commandDriveOwned( - drive, - async (state, reader) => { - const operation = state.operation; - if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) - ) { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - const run = operation.state; - const currentBatchState = operation.state.phase.batch; + return lane.mutateOperation( + toolOperation(lane), + async (_state, run, _meta, reader) => { + const currentBatchState = run.batch; const current = findCall(currentBatchState, call.sourceIndex, call.resultEntryId); if ( current === undefined || @@ -377,9 +323,8 @@ async function stageOutcome( setValue(pendingEntry(current.resultEntryId), { type: "message", payload: message }), deleteValue(pendingToolOutput(drive.operationId, current.resultEntryId)), ...memos.map(({ address }) => deleteValue(address)), - setValue(operationStateValue(drive.operationId), nextRun), ], - next: { ...state, operation: { meta: operation.meta, state: nextRun } }, + operationState: nextRun, materialize: () => ({ kind: "committed" }) as const, }; }, @@ -392,20 +337,11 @@ async function clearReplayCheckpoint( drive: Drive, batch: ToolBatch, call: Extract, -): Promise<{ kind: "args"; args: Record } | { kind: "state_changed" } | LostOwnership> { - return lane.commandDriveOwned<{ kind: "args"; args: Record } | { kind: "state_changed" }>( - drive, +): Promise<{ kind: "args"; args: Record } | { kind: "state_changed" }> { + return lane.command<{ kind: "args"; args: Record } | { kind: "state_changed" }>( async (state, reader) => { - const operation = state.operation; - if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) - ) { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - const current = findCall(operation.state.phase.batch, call.sourceIndex, call.resultEntryId); + const run = state.operation!.state as RunToolsOperation; + const current = findCall(run.batch, call.sourceIndex, call.resultEntryId); if (current?.status !== "effect_pending") { return { kind: "return", result: { kind: "state_changed" } as const }; } @@ -430,26 +366,12 @@ async function clearReplayCheckpoint( async function readCheckpoint( lane: Lane, drive: Drive, - batch: ToolBatch, call: Extract, -): Promise< - { kind: "checkpoint"; value: AgentToolResult | undefined } | { kind: "state_changed" } | LostOwnership -> { - return lane.commandDriveOwned< - { kind: "checkpoint"; value: AgentToolResult | undefined } | { kind: "state_changed" } - >( - drive, +): Promise<{ kind: "checkpoint"; value: AgentToolResult | undefined } | { kind: "state_changed" }> { + return lane.command<{ kind: "checkpoint"; value: AgentToolResult | undefined } | { kind: "state_changed" }>( async (state, reader) => { - const operation = state.operation; - if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) - ) { - return { kind: "return", result: { kind: "state_changed" } as const }; - } - const current = findCall(operation.state.phase.batch, call.sourceIndex, call.resultEntryId); + const run = state.operation!.state as RunToolsOperation; + const current = findCall(run.batch, call.sourceIndex, call.resultEntryId); if (current?.status !== "effect_pending") { return { kind: "return", result: { kind: "state_changed" } as const }; } @@ -545,7 +467,6 @@ async function executeAndStage( return stageOutcome( lane, drive, - batch, call, recovery ? interruptedMessage(cleared.toolCall, undefined) : abortedMessage(cleared.toolCall), false, @@ -582,7 +503,7 @@ async function executeAndStage( if (!(error instanceof AbortRequested)) throw error; patch = undefined; } - const current = currentBatch(lane, drive, batch); + const current = currentBatch(lane); const cancelled = current?.run.control.status === "cancel_requested"; const finalized = finalizeToolCall(cleared, executed, cancelled ? { ...patch, terminate: false } : patch); await lane.emitBatch( @@ -602,102 +523,81 @@ async function executeAndStage( ], drive.context, ); - return stageOutcome( - lane, - drive, - batch, - call, - createToolResultMessage(finalized), - cancelled ? false : finalized.terminate, - ); + return stageOutcome(lane, drive, call, createToolResultMessage(finalized), cancelled ? false : finalized.terminate); } async function readPlacement( lane: Lane, drive: Drive, - batch: ToolBatch, -): Promise { - return lane.commandDriveOwned( - drive, - async (state, reader) => { - const operation = state.operation; +): Promise { + return lane.command(async (state, reader) => { + const operation = state.operation; + if (operation?.state.at !== "run.tools") return { kind: "return", result: { kind: "none" } as const }; + const current = operation.state.batch; + let first = current.calls.findIndex((call) => call.status !== "completed"); + if (first === -1) return { kind: "return", result: { kind: "none" } as const }; + const ready: Array> = []; + while (first < current.calls.length) { + const call = current.calls[first]!; + if (call.status !== "outcome_ready") break; + ready.push(call); + first += 1; + } + if (ready.length === 0) return { kind: "return", result: { kind: "none" } as const }; + + const assistantEntry = (await reader.getEntries([current.assistantEntryId], drive.context)).get( + current.assistantEntryId, + ); + if (assistantEntry?.type !== "message" || assistantEntry.message.role !== "assistant") { + throw new SessionInvariantError("Tool placement assistant entry is invalid"); + } + const items: PlacementItem[] = []; + for (const call of ready) { + const stored = await reader.getValue(pendingEntry(call.resultEntryId), drive.context); + if (stored?.value.type !== "message" || !isToolResultMessage(stored.value.payload)) { + throw new SessionInvariantError(`Tool call ${call.resultEntryId} is missing its staged result`); + } + const block = assistantEntry.message.content[call.sourceIndex]; if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) + block?.type !== "toolCall" || + stored.value.payload.toolCallId !== block.id || + stored.value.payload.toolName !== block.name ) { - return { kind: "return", result: { kind: "none" } as const }; + throw new SessionInvariantError(`Tool call ${call.resultEntryId} has a mismatched staged result`); } - const current = operation.state.phase.batch; - let first = current.calls.findIndex((call) => call.status !== "completed"); - if (first === -1) return { kind: "return", result: { kind: "none" } as const }; - const ready: Array> = []; - while (first < current.calls.length) { - const call = current.calls[first]!; - if (call.status !== "outcome_ready") break; - ready.push(call); - first += 1; - } - if (ready.length === 0) return { kind: "return", result: { kind: "none" } as const }; + items.push({ call, message: stored.value.payload }); + } - const assistantEntry = (await reader.getEntries([current.assistantEntryId], drive.context)).get( - current.assistantEntryId, - ); - if (assistantEntry?.type !== "message" || assistantEntry.message.role !== "assistant") { - throw new SessionInvariantError("Tool placement assistant entry is invalid"); - } - const items: PlacementItem[] = []; - for (const call of ready) { - const stored = await reader.getValue(pendingEntry(call.resultEntryId), drive.context); - if (stored?.value.type !== "message" || !isToolResultMessage(stored.value.payload)) { - throw new SessionInvariantError(`Tool call ${call.resultEntryId} is missing its staged result`); + let turnResults: ToolResultMessage[] | undefined; + if (first === current.calls.length) { + const placedIds = current.calls + .filter((call) => call.status === "completed") + .map((call) => call.resultEntryId); + const placed = await reader.getEntries(placedIds, drive.context); + const staged = new Map(items.map((item) => [item.call.resultEntryId, item.message])); + turnResults = current.calls.map((call) => { + const message = + staged.get(call.resultEntryId) ?? + (() => { + const entry = placed.get(call.resultEntryId); + return entry?.type === "message" && isToolResultMessage(entry.message) ? entry.message : undefined; + })(); + if (message === undefined) { + throw new SessionInvariantError(`Completed tool call ${call.resultEntryId} is missing its result entry`); } - const block = assistantEntry.message.content[call.sourceIndex]; - if ( - block?.type !== "toolCall" || - stored.value.payload.toolCallId !== block.id || - stored.value.payload.toolName !== block.name - ) { - throw new SessionInvariantError(`Tool call ${call.resultEntryId} has a mismatched staged result`); - } - items.push({ call, message: stored.value.payload }); - } - - let turnResults: ToolResultMessage[] | undefined; - if (first === current.calls.length) { - const placedIds = current.calls - .filter((call) => call.status === "completed") - .map((call) => call.resultEntryId); - const placed = await reader.getEntries(placedIds, drive.context); - const staged = new Map(items.map((item) => [item.call.resultEntryId, item.message])); - turnResults = current.calls.map((call) => { - const message = - staged.get(call.resultEntryId) ?? - (() => { - const entry = placed.get(call.resultEntryId); - return entry?.type === "message" && isToolResultMessage(entry.message) ? entry.message : undefined; - })(); - if (message === undefined) { - throw new SessionInvariantError( - `Completed tool call ${call.resultEntryId} is missing its result entry`, - ); - } - return message; - }); - } - return { - kind: "return", - result: { - kind: "placement", - items, - assistant: assistantEntry.message, - ...(turnResults === undefined ? {} : { turnResults }), - }, - }; - }, - drive.context, - ); + return message; + }); + } + return { + kind: "return", + result: { + kind: "placement", + items, + assistant: assistantEntry.message, + ...(turnResults === undefined ? {} : { turnResults }), + }, + }; + }, drive.context); } async function commitPlacement( @@ -709,37 +609,10 @@ async function commitPlacement( const usageIds = read.items.map((item) => item.message.usage === undefined ? undefined : lane.session.idGenerator.next(), ); - return lane.commandDriveOwned( - drive, - async (state, reader) => { - const operation = state.operation; - if ( - operation === null || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" || - !batchMatches(operation.state.phase.batch, batch) - ) { - return { kind: "return", result: { kind: "none" } as const }; - } - const run = operation.state; - const current = operation.state.phase.batch; - const first = current.calls.findIndex((call) => call.status !== "completed"); - if (first === -1) return { kind: "return", result: { kind: "none" } as const }; - for (const [offset, item] of read.items.entries()) { - const call = current.calls[first + offset]; - if ( - call?.status !== "outcome_ready" || - call.sourceIndex !== item.call.sourceIndex || - call.resultEntryId !== item.call.resultEntryId - ) { - return { kind: "return", result: { kind: "none" } as const }; - } - const stored = await reader.getValue(pendingEntry(call.resultEntryId), drive.context); - if (stored?.value.type !== "message" || !isToolResultMessage(stored.value.payload)) { - throw new SessionInvariantError(`Tool call ${call.resultEntryId} is missing its staged result`); - } - } - + return lane.mutateOperation( + toolOperation(lane), + async (state, run, _meta, reader) => { + const current = run.batch; const writes: Write[] = []; const eventEntries: Array<{ entry: NewEntry; seqIndex: number }> = []; const eventUsage: Array<{ row: Omit; seqIndex: number }> = []; @@ -801,19 +674,18 @@ async function commitPlacement( } writes.push(setValue(branchTip(lane.name), parentId)); - let nextRun: RunState; + let nextRun: OperationState; if (complete) { const allTerminate = completedCalls.every((call) => call.status === "completed" && call.terminate); - nextRun = { - ...run, - phase: { - kind: "checkpoint", - continuation: allTerminate - ? { kind: "may_finish", includeFinalAssistant: false } - : { kind: "need_assistant", overflowRecoveryUsed: false }, - triggerEntryId: parentId!, - }, + const checkpoint: RunCheckpointOperation = { + ...runScopeOf(run), + at: "run.checkpoint", + continuation: allTerminate + ? { kind: "may_finish", includeFinalAssistant: false } + : { kind: "need_assistant", overflowRecoveryUsed: false }, + triggerEntryId: parentId!, }; + nextRun = checkpoint; const args = await reader.scanValues( operationToolArgsPrefix(drive.operationId, batch.turnId), drive.context, @@ -822,16 +694,11 @@ async function commitPlacement( } else { nextRun = withBatch(run, { ...current, calls: completedCalls }); } - writes.push(setValue(operationStateValue(drive.operationId), nextRun)); return { kind: "commit", writes, - next: { - ...state, - tipId: parentId, - configuration: nextConfiguration, - operation: { meta: operation.meta, state: nextRun }, - }, + operationState: nextRun, + lane: { tipId: parentId, configuration: nextConfiguration }, materialize: () => ({ kind: "committed", complete }) as const, events: (commit) => { const events: HarnessEvent[] = []; @@ -874,8 +741,8 @@ async function materializeReady( batch: ToolBatch, recovery: boolean, ): Promise { - const read = await readPlacement(lane, drive, batch); - if (read.kind === "lost_ownership" || read.kind === "none") return read; + const read = await readPlacement(lane, drive); + if (read.kind === "none") return read; await lane.emitBatch( read.items.flatMap(({ call, message }) => [ { @@ -928,7 +795,7 @@ async function beginFreshCall( ): Promise<{ job: Promise }> { const toolCall = toolCallFor(sources, call); if (sources.assistant.stopReason === "length") { - return { job: stageOutcome(lane, drive, batch, call, truncatedMessage(toolCall), false) }; + return { job: stageOutcome(lane, drive, call, truncatedMessage(toolCall), false) }; } const prepared = prepareToolCall(toolCall, tools); if ("kind" in prepared) { @@ -939,7 +806,7 @@ async function beginFreshCall( terminate: prepared.terminate, }; return { - job: stageOutcome(lane, drive, batch, call, createToolResultMessage(finalized), finalized.terminate), + job: stageOutcome(lane, drive, call, createToolResultMessage(finalized), finalized.terminate), }; } @@ -960,7 +827,7 @@ async function beginFreshCall( } catch (error) { if (!(error instanceof AbortRequested)) throw error; await error.cancellation; - return { job: stageOutcome(lane, drive, batch, call, abortedMessage(toolCall), false) }; + return { job: stageOutcome(lane, drive, call, abortedMessage(toolCall), false) }; } const cleared = applyBeforeToolDecision(prepared, decision); if ("kind" in cleared) { @@ -971,17 +838,12 @@ async function beginFreshCall( terminate: cleared.terminate, }; return { - job: stageOutcome(lane, drive, batch, call, createToolResultMessage(finalized), finalized.terminate), + job: stageOutcome(lane, drive, call, createToolResultMessage(finalized), finalized.terminate), }; } const intent = await commitIntent(lane, drive, batch, call, cleared.args, cleared.tool.replay ?? "never"); if (intent.kind !== "committed") { - return { - job: - intent.kind === "lost_ownership" - ? Promise.resolve(intent) - : stageOutcome(lane, drive, batch, call, abortedMessage(toolCall), false), - }; + return { job: stageOutcome(lane, drive, call, abortedMessage(toolCall), false) }; } const effectPending: Extract = { status: "effect_pending", @@ -1012,80 +874,62 @@ async function beginRecoveryCall( const cleared: ClearedToolCall = { toolCall, tool, args: replay.args }; return { job: executeAndStage(lane, drive, batch, call, cleared, toolContext, true) }; } - const checkpoint = await readCheckpoint(lane, drive, batch, call); + const checkpoint = await readCheckpoint(lane, drive, call); if (checkpoint.kind !== "checkpoint") return { job: Promise.resolve(checkpoint) }; return { - job: stageOutcome(lane, drive, batch, call, interruptedMessage(toolCall, checkpoint.value), false), + job: stageOutcome(lane, drive, call, interruptedMessage(toolCall, checkpoint.value), false), }; } -async function runCancelled( - lane: Lane, - drive: Drive, - batch: ToolBatch, - sources: BatchSource, - recovery: boolean, -): Promise { - for (let transition = 0; transition <= batch.calls.length * 2 + 1; transition += 1) { - const placement = await materializeReady(lane, drive, batch, recovery); - if (placement.kind === "lost_ownership") return placement; - const current = currentBatch(lane, drive, batch); - if (current === undefined) return { kind: "continue" }; - const call = current.batch.calls.find((candidate) => candidate.status !== "completed"); - if (call === undefined) throw new SessionInvariantError("Tool batch remained open after every call completed"); - if (call.status === "outcome_ready") { - throw new SessionInvariantError("Ready tool outcome was not materialized"); - } - const toolCall = toolCallFor(sources, call); - let staged: LocalResult; - if (call.status === "planned") { - staged = await stageOutcome(lane, drive, batch, call, abortedMessage(toolCall), false); - } else { - const checkpoint = await readCheckpoint(lane, drive, batch, call); - if (checkpoint.kind === "lost_ownership") return checkpoint; - if (checkpoint.kind === "state_changed") continue; - staged = await stageOutcome(lane, drive, batch, call, interruptedMessage(toolCall, checkpoint.value), false); - } - if (staged.kind === "lost_ownership") return staged; - } - throw new SessionInvariantError("Cancelled tool batch exceeded its bounded transition count"); -} - async function runSequential( lane: Lane, drive: Drive, batch: ToolBatch, sources: BatchSource, - tools: AgentHarnessTool[], - toolsByName: Map>, - toolContext: TContext, + execution: + | { + tools: AgentHarnessTool[]; + toolsByName: Map>; + toolContext: TContext; + } + | undefined, recovery: boolean, ): Promise { for (let transition = 0; transition <= batch.calls.length * 2 + 1; transition += 1) { - const placement = await materializeReady(lane, drive, batch, recovery); - if (placement.kind === "lost_ownership") return placement; - const current = currentBatch(lane, drive, batch); + await materializeReady(lane, drive, batch, recovery); + const current = currentBatch(lane); if (current === undefined) return { kind: "continue" }; const call = current.batch.calls.find((candidate) => candidate.status !== "completed"); if (call === undefined) throw new SessionInvariantError("Tool batch remained open after every call completed"); - if (call.status === "outcome_ready") { - throw new SessionInvariantError("Ready tool outcome was not materialized"); + if (call.status === "outcome_ready") throw new SessionInvariantError("Ready tool outcome was not materialized"); + + if (current.run.control.status === "cancel_requested") { + const toolCall = toolCallFor(sources, call); + if (call.status === "planned") { + await stageOutcome(lane, drive, call, abortedMessage(toolCall), false); + } else { + const checkpoint = await readCheckpoint(lane, drive, call); + if (checkpoint.kind === "state_changed") continue; + await stageOutcome(lane, drive, call, interruptedMessage(toolCall, checkpoint.value), false); + } + continue; } + + if (execution === undefined) throw new SessionInvariantError("Running tool batch is missing execution context"); const started = call.status === "planned" - ? await beginFreshCall(lane, drive, batch, sources, call, tools, toolContext, recovery) + ? await beginFreshCall(lane, drive, batch, sources, call, execution.tools, execution.toolContext, recovery) : await beginRecoveryCall( lane, drive, batch, sources, call, - toolsByName, - toolContext, - current.run.control.status === "cancel_requested", + execution.toolsByName, + execution.toolContext, + false, ); - const result = await started.job; - if (result.kind === "lost_ownership") return result; + await started.job; } throw new SessionInvariantError("Sequential tool batch exceeded its bounded transition count"); } @@ -1107,11 +951,11 @@ async function runParallel( return scheduled; }; const jobs: Promise[] = []; - const snapshot = currentBatch(lane, drive, batch); + const snapshot = currentBatch(lane); if (snapshot === undefined) return { kind: "continue" }; for (const call of snapshot.batch.calls) { if (call.status === "completed" || call.status === "outcome_ready") continue; - const current = currentBatch(lane, drive, batch); + const current = currentBatch(lane); if (current === undefined) break; const started = call.status === "planned" @@ -1133,10 +977,8 @@ async function runParallel( void job.catch(() => {}); jobs.push(job); } - const results = await Promise.all(jobs); - if (results.some((result) => result.kind === "lost_ownership")) return { kind: "lost_ownership" }; - const placement = await scheduleMaterialization(); - if (placement.kind === "lost_ownership") return placement; + await Promise.all(jobs); + await scheduleMaterialization(); return { kind: "continue" }; } @@ -1144,12 +986,9 @@ async function runParallel( export async function runTools( lane: Lane, drive: Drive, - run: RunState, - batch: ToolBatch, + run: RunToolsOperation, ): Promise { - if (run.phase.kind !== "tools" || !batchMatches(run.phase.batch, batch)) { - throw new SessionInvariantError("runTools requires its current tool batch"); - } + const batch = run.batch; const recovery = batch.calls.some((call) => call.status === "effect_pending" || call.status === "outcome_ready"); if (recovery) { await lane.emitBatch( @@ -1165,25 +1004,19 @@ export async function runTools( drive.context, ); } - const initialPlacement = await materializeReady(lane, drive, batch, recovery); - if (initialPlacement.kind === "lost_ownership") return initialPlacement; - if (currentBatch(lane, drive, batch) === undefined) return { kind: "continue" }; + await materializeReady(lane, drive, batch, recovery); + if (currentBatch(lane) === undefined) return { kind: "continue" }; const sources = await readBatchSource(lane, drive, batch); - if (sources.kind === "lost_ownership") return sources; - const beforeResolution = currentBatch(lane, drive, batch); - if (beforeResolution === undefined) return { kind: "continue" }; - if (beforeResolution.run.control.status === "cancel_requested") { - return runCancelled(lane, drive, batch, sources, recovery); + if (toolOperation(lane).control.status === "cancel_requested") { + return runSequential(lane, drive, batch, sources, undefined, recovery); } const config = lane.readConfig(); const active = new Set(batch.configuration.activeToolNames); const tools = config.tools.filter((tool) => active.has(tool.name)); const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); const toolContext = await resolveToolContext(lane, drive); - const current = currentBatch(lane, drive, batch); - if (current === undefined) return { kind: "continue" }; - return current.run.settings.toolExecution === "sequential" - ? runSequential(lane, drive, batch, sources, tools, toolsByName, toolContext, recovery) + return toolOperation(lane).settings.toolExecution === "sequential" + ? runSequential(lane, drive, batch, sources, { tools, toolsByName, toolContext }, recovery) : runParallel(lane, drive, batch, sources, tools, toolsByName, toolContext, recovery); } diff --git a/packages/agent/src/harness/runtime/lane.ts b/packages/agent/src/harness/runtime/lane.ts index 5f5bdf21b..290b19841 100644 --- a/packages/agent/src/harness/runtime/lane.ts +++ b/packages/agent/src/harness/runtime/lane.ts @@ -33,21 +33,25 @@ import { } from "../result.ts"; import { insertEntry } from "../session/commit.ts"; import { SessionInvariantError, SessionPendingAssistantMessageError } from "../session/session.ts"; -import type { - BranchScan, - Entry, - JsonValue, - LaneLastResult, - Operation, - PendingEntry, - Session, - SessionReader, - StructuralDecision, +import { + type BranchScan, + type Entry, + isRunOperationState, + type JsonValue, + type LaneLastResult, + type Operation, + type OperationMeta, + type OperationState, + type PendingEntry, + type RunStartingOperation, + type Session, + type SessionReader, } from "../session/types.ts"; import { branchTip, deleteValue, laneConfig, + laneLastResult as laneLastResultValue, laneState as laneStateValue, operationMeta as operationMetaValue, operationState as operationStateValue, @@ -63,7 +67,7 @@ import { type Drive, type LaneCommand, type LaneState, - type LostOwnership, + type OperationCommand, SliceNotImplemented, } from "./types.ts"; @@ -80,28 +84,29 @@ function isPromiseLike(value: unknown): value is PromiseLike { return "then" in value && typeof value.then === "function"; } -function structuralModel(structural: StructuralDecision): ModelIdentity | undefined { - return structural.status === "generating" ? structural.generation.context.configuration.model : undefined; -} - function capturedModel(operation: Operation): ModelIdentity | undefined { const { state } = operation; - if (state.kind === "compaction") return structuralModel(state.structural); - if (state.kind === "navigation") { - return state.summarize ? structuralModel(state.phase.structural) : undefined; - } - switch (state.phase.kind) { - case "assistant": - return state.phase.generation.context.configuration.model; - case "tools": - return state.phase.batch.configuration.model; - case "compaction": - return structuralModel(state.phase.structural); - case "deferred": - return state.phase.deferred.configuration.model; - case "starting": - case "checkpoint": - case "failure_drain": + switch (state.at) { + case "run.assistant.ready": + case "run.assistant.effect_pending": + case "run.assistant.retry_wait": + return state.generationContext.configuration.model; + case "run.tools": + return state.batch.configuration.model; + case "run.deferred.suspended": + case "run.deferred.effect_pending": + return state.configuration.model; + case "run.compaction.ready": + case "run.compaction.effect_pending": + case "run.compaction.retry_wait": + case "compaction.ready": + case "compaction.effect_pending": + case "compaction.retry_wait": + case "navigation.summary.ready": + case "navigation.summary.effect_pending": + case "navigation.summary.retry_wait": + return state.summaryContext.configuration.model; + default: return undefined; } } @@ -158,10 +163,6 @@ export class Lane implements AgentLane { return this.config(); } - isDriveActive(drive: Drive): boolean { - return this.activeDrive === drive; - } - mismatch(expected: string, currentOperationId: string | null, last: LaneLastResult | undefined): OperationMismatch { return new OperationMismatch({ lane: this.name, @@ -186,10 +187,8 @@ export class Lane implements AgentLane { * * Planner, commit, and materialization errors fault the harness before releasing the Session line. Close/fault gates * are checked both before queueing and when the callback starts: close-first rejects, while a callback admitted - * before close may finish its commit, publish memory, and resolve without another open check. Drive-owned commands - * additionally need a final fence because their exact owner may be removed outside the Session line. Never invoke - * providers, tools, hooks, - * timers, event handlers, or wait for task completion here; perform those after `command()` returns. + * before close may finish its commit, publish memory, and resolve without another open check. Never invoke providers, + * tools, hooks, timers, event handlers, or wait for task completion here; perform those after `command()` returns. */ async command( plan: (state: LaneState, reader: SessionReader) => LaneCommand | Promise>, @@ -233,64 +232,73 @@ export class Lane implements AgentLane { return outcome.result; } - /** - * Run one drive-owned command with an exact-object fence at commit admission. - * Durable control state comes from the authoritative projection; storage reads are only for referenced payloads. - * Unlike an ordinary command, owner abandonment can occur outside the Session line while the planner awaits. The final - * open check preserves close/fault as lifecycle errors before the exact-owner check can report lost ownership. - */ - async commandDriveOwned( - drive: Drive, - plan: (state: LaneState, reader: SessionReader) => LaneCommand | Promise>, + /** Commit one transition for the operation owned by the current Drive continuation. */ + mutateOperation( + _current: TState, + plan: ( + state: LaneState, + current: TState, + meta: OperationMeta, + reader: SessionReader, + ) => OperationCommand | Promise>, context: Context, - ): Promise { - this.assertOpen(); - let outcome: LaneCommandOutcome; - try { - outcome = await this.session.mutate(async (mutator) => { - this.assertOpen(); - const operation = this.state.operation; - if (operation?.meta.operationId !== drive.operationId || this.activeDrive !== drive) { - return { kind: "return", result: { kind: "lost_ownership" } as const }; - } - try { - const decision = await plan(this.state, mutator); - switch (decision.kind) { - case "return": - return { kind: "return", result: decision.result }; - case "reject": - return { kind: "reject", error: decision.error }; - case "commit": { - // Keep these checks adjacent to commit admission. Owner abandonment may occur - // outside the line; close/fault must still win classification over ownership loss. - this.assertOpen(); - if (this.activeDrive !== drive) { - return { kind: "return", result: { kind: "lost_ownership" } as const }; - } - const commitPromise = mutator.commit(decision.writes, context); - const commit = await commitPromise; - this.state = decision.next; - const result = decision.materialize(commit); - if (isPromiseLike(result)) { - throw new TypeError("Lane command materialize() must be synchronous"); - } - const events = decision.events?.(commit) ?? []; - const delivery = events.length === 0 ? undefined : this.emitBatch(events, context); - return { kind: "return", result, ...(delivery === undefined ? {} : { delivery }) }; - } - } - } catch (error) { - if (this.closedError !== undefined) throw this.closedError; - throw this.onFault(error, context); - } - }, context); - } catch (error) { - if (this.closedError !== undefined) throw this.closedError; - throw error; - } - if (outcome.kind === "reject") throw outcome.error; - await outcome.delivery; - return outcome.result; + ): Promise { + return this.command(async (state, reader) => { + const operation = state.operation!; + const decision = await plan(state, operation.state as TState, operation.meta, reader); + if (decision.kind === "commit") { + return { + kind: "commit", + writes: [ + ...decision.writes, + setValue(operationStateValue(operation.meta.operationId), decision.operationState), + ], + next: { + ...state, + ...decision.lane, + operation: { meta: operation.meta, state: decision.operationState }, + }, + materialize: decision.materialize, + ...(decision.events === undefined ? {} : { events: decision.events }), + }; + } + if (decision.kind !== "finish") return decision; + return { + kind: "commit", + writes: [ + ...decision.writes, + setValue(laneLastResultValue(this.name), decision.lastResult), + setValue(laneStateValue(this.name), { + currentOperationId: null, + pendingNextRun: state.pendingNextRun, + }), + ], + next: { ...state, ...decision.lane, lastResult: decision.lastResult, operation: null }, + materialize: decision.materialize, + ...(decision.events === undefined ? {} : { events: decision.events }), + }; + }, context); + } + + /** Ordinary progress declines when explicit durable cancellation has already won. */ + advanceOperation( + current: TState, + plan: ( + state: LaneState, + current: TState, + meta: OperationMeta, + reader: SessionReader, + ) => OperationCommand | Promise>, + context: Context, + ): Promise { + return this.mutateOperation( + current, + (state, latest, meta, reader) => + latest.control.status === "cancel_requested" + ? { kind: "return", result: undefined } + : plan(state, latest, meta, reader), + context, + ); } async accept(request: OperationRequest, context: Context): Promise { @@ -428,16 +436,15 @@ export class Lane implements AgentLane { startedAt, intent: { kind: "run" as const, promptEntryIds: prompt.map(({ id }) => id) }, }; - const operationState = { - kind: "run" as const, - control: { status: "running" as const }, + const operationState: RunStartingOperation = { + at: "run.starting", + control: { status: "running" }, settings: { compaction: acceptanceConfig.compaction, steeringMode: acceptanceConfig.steeringMode, followUpMode: acceptanceConfig.followUpMode, toolExecution: acceptanceConfig.toolExecution, }, - phase: { kind: "starting" as const }, inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; @@ -683,17 +690,15 @@ export class Lane implements AgentLane { ); const nextRun = await queuedItems(captured.pendingNextRun, "Pending next-run entry"); const operation = captured.operation; - const steer = - operation?.state.kind === "run" ? await queuedItems(operation.state.inbox.steer, "Steer entry") : []; + const runState = operation !== null && isRunOperationState(operation.state) ? operation.state : undefined; + const steer = runState === undefined ? [] : await queuedItems(runState.inbox.steer, "Steer entry"); const followUp = - operation?.state.kind === "run" - ? await queuedItems(operation.state.inbox.followUp, "Follow-up entry") - : []; + runState === undefined ? [] : await queuedItems(runState.inbox.followUp, "Follow-up entry"); // TODO does a client really need pending writes? We wouldn't visualize those, any other uses for them? const pendingWrites = - operation?.state.kind === "run" + runState !== undefined ? await Promise.all( - operation.state.inbox.writes.map(async (entryId) => { + runState.inbox.writes.map(async (entryId) => { const stored = await reader.getValue(pendingEntry(entryId), context); if (stored === undefined) { throw new SessionInvariantError(`Pending write ${entryId} is missing its payload`); @@ -734,103 +739,81 @@ export class Lane implements AgentLane { } return reduceAssistantMessageFrames(frames); }; - const readRetry = (generation: { - status: string; - nextAttempt?: number; - notBefore?: number; - context: { retryPolicy: { maxAttempts: number } }; - }) => { - if (generation.status !== "retry_wait") return undefined; - if (generation.nextAttempt === undefined || generation.notBefore === undefined) { - throw new SessionInvariantError("Retry wait is missing retry metadata"); - } - return { - attempt: generation.nextAttempt, - maxAttempts: generation.context.retryPolicy.maxAttempts, - nextAttemptAt: generation.notBefore, - }; - }; - const readStructuralRetry = (structural: StructuralDecision) => { - if (structural.status !== "generating") return undefined; - return readRetry(structural.generation); - }; - - if (operation.state.kind === "run") { - switch (operation.state.phase.kind) { - case "assistant": - retry = readRetry(operation.state.phase.generation); - if (operation.state.phase.generation.status === "effect_pending") { - streamingMessage = await readStreamingMessage( - operation.state.phase.generation.responseEntryId, - ); - } - break; - case "deferred": { - const durable = operation.state.phase.deferred; - const source = (await reader.getEntries([durable.sourceEntryId], context)).get( - durable.sourceEntryId, - ); - if ( - source?.type !== "message" || - source.message.role !== "assistant" || - source.message.deferred === undefined - ) { - throw new SessionInvariantError("Deferred source is missing its assistant handle"); - } - deferred = { handle: source.message.deferred, poll: durable.poll }; - if (durable.status === "effect_pending") { - streamingMessage = await readStreamingMessage(durable.responseEntryId); - } - break; + const state = operation.state; + switch (state.at) { + case "run.assistant.retry_wait": + retry = { + attempt: state.nextAttempt, + maxAttempts: state.generationContext.retryPolicy.maxAttempts, + nextAttemptAt: state.notBefore, + }; + break; + case "run.assistant.effect_pending": + streamingMessage = await readStreamingMessage(state.responseEntryId); + break; + case "run.deferred.suspended": + case "run.deferred.effect_pending": { + const source = (await reader.getEntries([state.sourceEntryId], context)).get(state.sourceEntryId); + if ( + source?.type !== "message" || + source.message.role !== "assistant" || + source.message.deferred === undefined + ) { + throw new SessionInvariantError("Deferred source is missing its assistant handle"); } - case "tools": { - const { batch } = operation.state.phase; - const assistant = (await reader.getEntries([batch.assistantEntryId], context)).get( - batch.assistantEntryId, - ); - if (assistant?.type !== "message" || assistant.message.role !== "assistant") { - throw new SessionInvariantError("Tool batch assistant entry is invalid"); - } - for (const call of batch.calls) { - if (call.status !== "effect_pending") continue; - const block = assistant.message.content[call.sourceIndex]; - if (block?.type !== "toolCall") { - throw new SessionInvariantError( - `Tool call source index ${call.sourceIndex} does not name a tool-call block`, - ); - } - const args = await reader.getValue( - operationToolArgs(operation.meta.operationId, batch.turnId, call.sourceIndex), - context, - ); - if (args === undefined) { - throw new SessionInvariantError(`Tool call ${block.id} is missing persisted arguments`); - } - const checkpoint = await reader.getValue( - pendingToolOutput(operation.meta.operationId, call.resultEntryId), - context, - ); - runningTools.push({ - toolCallId: block.id, - toolName: block.name, - args: args.value, - ...(checkpoint === undefined ? {} : { partialResult: checkpoint.value }), - }); - } - break; + deferred = { handle: source.message.deferred, poll: state.poll }; + if (state.at === "run.deferred.effect_pending") { + streamingMessage = await readStreamingMessage(state.responseEntryId); } - case "compaction": - retry = readStructuralRetry(operation.state.phase.structural); - break; - case "starting": - case "checkpoint": - case "failure_drain": - break; + break; } - } else if (operation.state.kind === "compaction") { - retry = readStructuralRetry(operation.state.structural); - } else if (operation.state.summarize) { - retry = readStructuralRetry(operation.state.phase.structural); + case "run.tools": { + const { batch } = state; + const assistant = (await reader.getEntries([batch.assistantEntryId], context)).get( + batch.assistantEntryId, + ); + if (assistant?.type !== "message" || assistant.message.role !== "assistant") { + throw new SessionInvariantError("Tool batch assistant entry is invalid"); + } + for (const call of batch.calls) { + if (call.status !== "effect_pending") continue; + const block = assistant.message.content[call.sourceIndex]; + if (block?.type !== "toolCall") { + throw new SessionInvariantError( + `Tool call source index ${call.sourceIndex} does not name a tool-call block`, + ); + } + const args = await reader.getValue( + operationToolArgs(operation.meta.operationId, batch.turnId, call.sourceIndex), + context, + ); + if (args === undefined) { + throw new SessionInvariantError(`Tool call ${block.id} is missing persisted arguments`); + } + const checkpoint = await reader.getValue( + pendingToolOutput(operation.meta.operationId, call.resultEntryId), + context, + ); + runningTools.push({ + toolCallId: block.id, + toolName: block.name, + args: args.value, + ...(checkpoint === undefined ? {} : { partialResult: checkpoint.value }), + }); + } + break; + } + case "run.compaction.retry_wait": + case "compaction.retry_wait": + case "navigation.summary.retry_wait": + retry = { + attempt: state.nextAttempt, + maxAttempts: state.summaryContext.retryPolicy.maxAttempts, + nextAttemptAt: state.notBefore, + }; + break; + default: + break; } const drained = @@ -977,7 +960,7 @@ export class Lane implements AgentLane { } const operation = state.operation; - if (operation.state.kind !== "run") { + if (!isRunOperationState(operation.state)) { return { kind: "reject", error: new Error(`Cannot append while structural operation ${operation.meta.operationId} is active`), diff --git a/packages/agent/src/harness/runtime/progress.ts b/packages/agent/src/harness/runtime/progress.ts index 08dc3bc49..23eff85d9 100644 --- a/packages/agent/src/harness/runtime/progress.ts +++ b/packages/agent/src/harness/runtime/progress.ts @@ -32,19 +32,15 @@ function openProgress( write(item) { if (sealed) return; const write = lane - .commandDriveOwned( - drive, - (projection) => { - if (!stillOwns(projection)) return { kind: "return", result: undefined }; - return { - kind: "commit", - writes: [commitWrite(item)], - next: projection, - materialize: () => undefined, - }; - }, - drive.context, - ) + .command((projection) => { + if (!stillOwns(projection)) return { kind: "return", result: undefined }; + return { + kind: "commit", + writes: [commitWrite(item)], + next: projection, + materialize: () => undefined, + }; + }, drive.context) .then(() => undefined); latest = write; void write.catch(() => {}); @@ -71,18 +67,12 @@ export function openFrameProgress( (frame) => appendList(address, frame), () => deleteList(address), (state) => { - const operation = state.operation; - if (operation?.meta.operationId !== drive.operationId || operation.state.kind !== "run") return false; - const run = operation.state; - if (run.phase.kind === "assistant") { - const generation = run.phase.generation; - return generation.status === "effect_pending" && generation.responseEntryId === responseEntryId; - } - if (run.phase.kind === "deferred") { - const deferred = run.phase.deferred; - return deferred.status === "effect_pending" && deferred.responseEntryId === responseEntryId; - } - return false; + const run = state.operation?.state; + if (run === undefined) return false; + return ( + (run.at === "run.assistant.effect_pending" || run.at === "run.deferred.effect_pending") && + run.responseEntryId === responseEntryId + ); }, ); } @@ -102,14 +92,8 @@ export function openToolProgress( () => deleteValue(address), (state) => { const operation = state.operation; - if ( - operation?.meta.operationId !== drive.operationId || - operation.state.kind !== "run" || - operation.state.phase.kind !== "tools" - ) { - return false; - } - const batch = operation.state.phase.batch; + if (operation?.state.at !== "run.tools") return false; + const batch = operation.state.batch; return ( batch.turnId === turnId && batch.calls.some( diff --git a/packages/agent/src/harness/runtime/restore.ts b/packages/agent/src/harness/runtime/restore.ts index 988b2701c..658b60b8d 100644 --- a/packages/agent/src/harness/runtime/restore.ts +++ b/packages/agent/src/harness/runtime/restore.ts @@ -78,9 +78,9 @@ export async function restoreLaneState(reader: SessionReader, lane: string, cont `Operation ${operationId} belongs to lane ${JSON.stringify(meta.value.lane)}, not ${JSON.stringify(lane)}`, ); } - if (meta.value.intent.kind !== state.value.kind) { + if (!state.value.at.startsWith(`${meta.value.intent.kind}.`)) { throw new SessionInvariantError( - `Operation ${operationId} intent ${meta.value.intent.kind} does not match state ${state.value.kind}`, + `Operation ${operationId} intent ${meta.value.intent.kind} does not match state ${state.value.at}`, ); } operation = { meta: meta.value, state: state.value }; diff --git a/packages/agent/src/harness/runtime/types.ts b/packages/agent/src/harness/runtime/types.ts index 11a5784d6..4dd082192 100644 --- a/packages/agent/src/harness/runtime/types.ts +++ b/packages/agent/src/harness/runtime/types.ts @@ -11,7 +11,14 @@ import type { import type { CompactionSettings } from "../compaction/compaction.ts"; import { type Context, withoutAbortSignal } from "../context.ts"; import { createGate, type Gate, type GateControl } from "../execution/effect-gate.ts"; -import type { CommitResult, LaneConfiguration, LaneLastResult, Operation, Write } from "../session/types.ts"; +import type { + CommitResult, + LaneConfiguration, + LaneLastResult, + Operation, + OperationState, + Write, +} from "../session/types.ts"; import type { AgentHarnessStreamOptions, AgentHarnessTool } from "../types.ts"; export class SliceNotImplemented extends Error { @@ -48,15 +55,34 @@ export interface LaneState { type Synchronous = TResult extends PromiseLike ? never : TResult; +interface CommitDecision { + kind: "commit"; + writes: Write[]; + materialize(commit: CommitResult): Synchronous; + events?(commit: CommitResult): HarnessEvent[]; +} + /** One effect-free decision made on a lane's serialized mutation line. */ export type LaneCommand = - | { - kind: "commit"; - writes: Write[]; - next: LaneState; - materialize(commit: CommitResult): Synchronous; - events?(commit: CommitResult): HarnessEvent[]; - } + | (CommitDecision & { next: LaneState }) + | { kind: "return"; result: TResult } + | { kind: "reject"; error: Error }; + +/** A durable operation transition. The Lane pairs the state write with projection publication. */ +type LanePatch = Partial>; + +interface FinishDecision { + kind: "finish"; + writes: Write[]; + lastResult: LaneLastResult; + lane?: LanePatch; + materialize(commit: CommitResult): Synchronous; + events?(commit: CommitResult): HarnessEvent[]; +} + +export type OperationCommand = + | (CommitDecision & { operationState: OperationState; lane?: LanePatch }) + | FinishDecision | { kind: "return"; result: TResult } | { kind: "reject"; error: Error }; @@ -66,10 +92,8 @@ export class Drive { readonly completion: Promise; readonly gate: Gate; readonly context: Context; - readonly installerSignal: AbortSignal | undefined; readonly waitForRetry: boolean; deferredPermits: number; - finalizedOutcome: DriveOutcome | undefined; private readonly control: GateControl; private readonly resolveCompletion: (outcome: DriveOutcome) => void; @@ -78,7 +102,6 @@ export class Drive { constructor(options: DriveOptions, context: Context) { this.operationId = options.operationId; - this.installerSignal = context.abortSignal; this.context = withoutAbortSignal(context); this.waitForRetry = options.waitForRetry ?? false; this.deferredPermits = options.pollDeferred === true ? 1 : 0; @@ -121,10 +144,7 @@ export class Drive { } } -export type LostOwnership = { kind: "lost_ownership" }; - export type ProcedureResult = | { kind: "continue" } | { kind: "waiting"; outcome: DriveOutcome } - | { kind: "settled"; outcome: TerminalOperationOutcome } - | LostOwnership; + | { kind: "settled"; outcome: TerminalOperationOutcome }; diff --git a/packages/agent/src/harness/session/testing/conformance/session-repo.ts b/packages/agent/src/harness/session/testing/conformance/session-repo.ts index 159293bbd..c5358827b 100644 --- a/packages/agent/src/harness/session/testing/conformance/session-repo.ts +++ b/packages/agent/src/harness/session/testing/conformance/session-repo.ts @@ -390,9 +390,9 @@ export function createSessionRepoForkBehaviorConformance; +export type CompactionOperationState = Extract; +export type NavigationOperationState = Extract; + +export function isRunOperationState(state: OperationState): state is RunOperationState { + return state.at.startsWith("run."); +} + +/** Copy only the run-wide scope fields when constructing a successor leaf. */ +export function runScopeOf(state: RunOperationState): RunScope { + return { + control: state.control, + settings: state.settings, + inbox: state.inbox, + latestAssistantEntryId: state.latestAssistantEntryId, + }; +} + +/** Copy only the checkpoint payload fields from a checkpoint-bearing state. */ +export function checkpointDataOf(state: CheckpointData): CheckpointData { + return { + continuation: state.continuation, + triggerEntryId: state.triggerEntryId, + ...(state.thresholdCheckedTriggerEntryId === undefined + ? {} + : { thresholdCheckedTriggerEntryId: state.thresholdCheckedTriggerEntryId }), + ...(state.skipInboxOnce === undefined ? {} : { skipInboxOnce: state.skipInboxOnce }), + }; +} export type Operation = { meta: OperationMeta; state: OperationState }; export interface LaneState { diff --git a/packages/agent/test/harness/execution-primitives.test.ts b/packages/agent/test/harness/execution-primitives.test.ts index 998f61063..4068f6268 100644 --- a/packages/agent/test/harness/execution-primitives.test.ts +++ b/packages/agent/test/harness/execution-primitives.test.ts @@ -8,7 +8,7 @@ import { withTelemetryContext, } from "../../src/harness/context.ts"; import { HarnessEventBus } from "../../src/harness/events.ts"; -import { AbortRequested, createGate, DriveAbandoned } from "../../src/harness/execution/effect-gate.ts"; +import { AbortRequested, createGate } from "../../src/harness/execution/effect-gate.ts"; import { HookRegistry } from "../../src/harness/hooks.ts"; import { InMemoryTelemetryContext } from "../../src/index.ts"; @@ -48,11 +48,6 @@ describe("createGate", () => { expect(() => gate.admit(() => undefined)).toThrow(error); expect(gate.signal.aborted).toBe(true); }); - - it("keeps invocation abandonment distinct from durable abort control flow", () => { - expect(new DriveAbandoned()).not.toBeInstanceOf(AbortRequested); - expect(new DriveAbandoned().name).toBe("DriveAbandoned"); - }); }); describe("HookRegistry", () => { diff --git a/packages/agent/test/harness/runtime/accept.test.ts b/packages/agent/test/harness/runtime/accept.test.ts index d3f9ff0e9..260d855a1 100644 --- a/packages/agent/test/harness/runtime/accept.test.ts +++ b/packages/agent/test/harness/runtime/accept.test.ts @@ -113,14 +113,13 @@ describe("runtime atomic run acceptance", () => { const admission = unwrap(await lane.accept(request, BACKGROUND_CONTEXT)); const operation = lane.state.operation; - if (operation?.state.kind !== "run") throw new Error("Expected accepted run"); + if (operation?.state.at !== "run.starting") throw new Error("Expected accepted run"); const entryId = operation.meta.intent.kind === "run" ? operation.meta.intent.promptEntryIds[0] : undefined; if (entryId === undefined) throw new Error("Expected prompt entry"); const entry = await session.getEntry(entryId, BACKGROUND_CONTEXT); expect(admission).toMatchObject({ operationId: operation.meta.operationId, kind: "run" }); expect(entry).toMatchObject({ type: "message", message: { role: "user", content: expectedContent } }); - expect(operation.state.phase).toEqual({ kind: "starting" }); expect(operation.state.settings).toEqual({ compaction: DEFAULT_COMPACTION_SETTINGS, steeringMode: "all", @@ -215,7 +214,7 @@ describe("runtime atomic run acceptance", () => { unwrap(await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT)); const operation = lane.state.operation; - if (operation?.meta.intent.kind !== "run" || operation.state.kind !== "run") { + if (operation?.meta.intent.kind !== "run" || operation.state.at !== "run.starting") { throw new Error("Expected accepted run"); } expect(operation.meta.intent.promptEntryIds).toEqual([]); diff --git a/packages/agent/test/harness/runtime/drive-generation.test.ts b/packages/agent/test/harness/runtime/drive-generation.test.ts index 3b5bb019c..c24b032b6 100644 --- a/packages/agent/test/harness/runtime/drive-generation.test.ts +++ b/packages/agent/test/harness/runtime/drive-generation.test.ts @@ -21,13 +21,16 @@ import { type Config, Drive } from "../../../src/harness/runtime/types.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts"; -import type { - Generation, - LaneConfiguration, - MessageEntry, - RunState, - Session, - Write, +import { + isRunOperationState, + type LaneConfiguration, + type MessageEntry, + type RunAssistantEffectPendingOperation, + type RunAssistantReadyOperation, + type RunOperationState, + runScopeOf, + type Session, + type Write, } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; import { deferred } from "./test-utils.ts"; @@ -144,26 +147,29 @@ async function createFixture(backend: MemoryStorage = new MemoryStorage({ now: ( return { lane, drive, session, storage, models, faux, hooks, events, config }; } -function currentRun(lane: Lane): RunState { +function currentRun(lane: Lane): RunOperationState { const operation = lane.state.operation; - if (operation?.state.kind !== "run") throw new Error("fixture has no run"); + if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); return operation.state; } -function readyGeneration(lane: Lane): Extract { +function readyGeneration(lane: Lane): RunAssistantReadyOperation { const run = currentRun(lane); - if (run.phase.kind !== "assistant" || run.phase.generation.status !== "ready") { - throw new Error("fixture has no ready generation"); - } - return run.phase.generation; + if (run.at !== "run.assistant.ready") throw new Error("fixture has no ready generation"); + return run; } -async function advanceToReady(fixture: Fixture): Promise> { - let run = currentRun(fixture.lane); - await startRun(fixture.lane, fixture.drive, run); - run = currentRun(fixture.lane); - if (run.phase.kind !== "checkpoint") throw new Error("run did not reach checkpoint"); - await runCheckpoint(fixture.lane, fixture.drive, run, run.phase); +async function startFixtureRun(fixture: Fixture) { + const run = currentRun(fixture.lane); + if (run.at !== "run.starting") throw new Error("run did not start at its initial boundary"); + return startRun(fixture.lane, fixture.drive, run); +} + +async function advanceToReady(fixture: Fixture): Promise { + await startFixtureRun(fixture); + const run = currentRun(fixture.lane); + if (run.at !== "run.checkpoint") throw new Error("run did not reach checkpoint"); + await runCheckpoint(fixture.lane, fixture.drive, run); return readyGeneration(fixture.lane); } @@ -179,16 +185,8 @@ function writesOperationState(writes: readonly Write[], status: string): boolean write.namespace === "pi.op.state" && typeof write.value === "object" && write.value !== null && - "phase" in write.value && - typeof write.value.phase === "object" && - write.value.phase !== null && - "kind" in write.value.phase && - write.value.phase.kind === "assistant" && - "generation" in write.value.phase && - typeof write.value.phase.generation === "object" && - write.value.phase.generation !== null && - "status" in write.value.phase.generation && - write.value.phase.generation.status === status, + "at" in write.value && + write.value.at === `run.assistant.${status}`, ); } @@ -202,21 +200,19 @@ describe("runtime generation checkpoint", () => { fixture.hooks.on("before_run", () => ({ messages: [{ role: "user", content: "injected", timestamp: 2 }], })); - const starting = currentRun(fixture.lane); - - expect(await startRun(fixture.lane, fixture.drive, starting)).toEqual({ kind: "continue" }); + expect(await startFixtureRun(fixture)).toEqual({ kind: "continue" }); let run = currentRun(fixture.lane); - if (run.phase.kind !== "checkpoint") throw new Error("missing checkpoint"); - expect(run.phase.triggerEntryId).toBe(fixture.lane.state.tipId); + if (run.at !== "run.checkpoint") throw new Error("missing checkpoint"); + expect(run.triggerEntryId).toBe(fixture.lane.state.tipId); expect(fixture.events.map((event) => event.type)).toContain("entry_added"); await expectProjectionRestores(fixture); - expect(await runCheckpoint(fixture.lane, fixture.drive, run, run.phase)).toEqual({ kind: "continue" }); + expect(await runCheckpoint(fixture.lane, fixture.drive, run)).toEqual({ kind: "continue" }); run = currentRun(fixture.lane); - if (run.phase.kind !== "assistant" || run.phase.generation.status !== "ready") { + if (run.at !== "run.assistant.ready") { throw new Error("missing ready generation"); } - expect(run.phase.generation.context).toMatchObject({ + expect(run.generationContext).toMatchObject({ configuration: fixture.lane.state.configuration, streamOptions: {}, retryPolicy: { maxAttempts: 4, baseDelayMs: 1 }, @@ -227,13 +223,13 @@ describe("runtime generation checkpoint", () => { it("drains mixed pending writes while separating leaf and generation trigger", async () => { const fixture = await createFixture(); - await startRun(fixture.lane, fixture.drive, currentRun(fixture.lane)); + await startFixtureRun(fixture); const messageId = "01950000-0000-7000-8000-000000000010"; const customId = "01950000-0000-7000-8000-000000000011"; await fixture.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextState: RunState = { + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + const nextState: RunOperationState = { ...operation.state, inbox: { ...operation.state.inbox, writes: [messageId, customId] }, }; @@ -256,15 +252,15 @@ describe("runtime generation checkpoint", () => { }; }, BACKGROUND_CONTEXT); const run = currentRun(fixture.lane); - if (run.phase.kind !== "checkpoint") throw new Error("missing checkpoint"); + if (run.at !== "run.checkpoint") throw new Error("missing checkpoint"); - await runCheckpoint(fixture.lane, fixture.drive, run, run.phase); + await runCheckpoint(fixture.lane, fixture.drive, run); const drained = currentRun(fixture.lane); - if (drained.phase.kind !== "checkpoint") throw new Error("missing drained checkpoint"); + if (drained.at !== "run.checkpoint") throw new Error("missing drained checkpoint"); expect(fixture.lane.state.tipId).toBe(customId); - expect(drained.phase.triggerEntryId).toBe(messageId); - expect(drained.phase).toMatchObject({ + expect(drained.triggerEntryId).toBe(messageId); + expect(drained).toMatchObject({ continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, skipInboxOnce: true, }); @@ -288,21 +284,18 @@ describe("runtime assistant generation", () => { fixture.faux.setResponses([ async () => { const state = await fixture.session.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT); - effectPendingAtProvider = - state?.value.kind === "run" && - state.value.phase.kind === "assistant" && - state.value.phase.generation.status === "effect_pending"; + effectPendingAtProvider = state?.value.at === "run.assistant.effect_pending"; return fauxAssistantMessage("answer", { timestamp: 5 }); }, ]); fixture.storage.clearCommitAttempts(); - const generating = runGeneration(fixture.lane, fixture.drive, currentRun(fixture.lane), ready); + const generating = runGeneration(fixture.lane, fixture.drive, ready); await hookStarted.promise; const steerId = "01950000-0000-7000-8000-000000000020"; await fixture.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextState: RunState = { + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + const nextState: RunOperationState = { ...operation.state, inbox: { ...operation.state.inbox, steer: [steerId] }, }; @@ -325,7 +318,7 @@ describe("runtime assistant generation", () => { expect(effectPendingAtProvider).toBe(true); const settled = currentRun(fixture.lane); expect(settled.inbox.steer).toEqual([steerId]); - if (settled.phase.kind !== "checkpoint") throw new Error("response did not reach checkpoint"); + if (settled.at !== "run.checkpoint") throw new Error("response did not reach checkpoint"); const responseId = settled.latestAssistantEntryId; if (responseId === null) throw new Error("missing response id"); const entry = await fixture.session.getEntry(responseId, BACKGROUND_CONTEXT); @@ -401,7 +394,7 @@ describe("runtime assistant generation", () => { backend.blockNextFrame = true; fixture.storage.clearCommitAttempts(); - const generating = runGeneration(fixture.lane, fixture.drive, currentRun(fixture.lane), ready); + const generating = runGeneration(fixture.lane, fixture.drive, ready); await backend.frameStarted.promise; await expect .poll(() => fixture.events.filter((event) => event.type === "message_update").length) @@ -433,13 +426,13 @@ describe("runtime assistant generation", () => { const ready = await advanceToReady(fixture); fixture.storage.clearCommitAttempts(); - expect(await runGeneration(fixture.lane, fixture.drive, currentRun(fixture.lane), ready)).toEqual({ + expect(await runGeneration(fixture.lane, fixture.drive, ready)).toEqual({ kind: "continue", }); const run = currentRun(fixture.lane); - expect(run.phase).toMatchObject({ - kind: "failure_drain", + expect(run).toMatchObject({ + at: "run.failure_drain", error: { code: "configured_tools_unavailable", details: { tools: ["missing"] } }, provenance: { kind: "configuration" }, }); @@ -459,12 +452,12 @@ describe("runtime assistant generation", () => { fixture.models.deleteProvider(fixture.faux.provider.id); fixture.storage.clearCommitAttempts(); - expect(await runGeneration(fixture.lane, fixture.drive, currentRun(fixture.lane), ready)).toEqual({ + expect(await runGeneration(fixture.lane, fixture.drive, ready)).toEqual({ kind: "continue", }); - expect(currentRun(fixture.lane).phase).toMatchObject({ - kind: "failure_drain", + expect(currentRun(fixture.lane)).toMatchObject({ + at: "run.failure_drain", error: { code: "model_unavailable" }, provenance: { kind: "configuration" }, }); @@ -478,7 +471,7 @@ describe("runtime assistant generation", () => { await expectProjectionRestores(fixture); }); - it("declines intent when cancellation or exact-owner replacement wins preparation", async () => { + it("declines intent when cancellation wins preparation", async () => { const cancelled = await createFixture(); const cancelledReady = await advanceToReady(cancelled); const cancelHookStarted = deferred(); @@ -489,12 +482,12 @@ describe("runtime assistant generation", () => { return undefined; }); cancelled.storage.clearCommitAttempts(); - const cancelling = runGeneration(cancelled.lane, cancelled.drive, currentRun(cancelled.lane), cancelledReady); + const cancelling = runGeneration(cancelled.lane, cancelled.drive, cancelledReady); await cancelHookStarted.promise; await cancelled.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextState: RunState = { + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + const nextState: RunOperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: 10, drainedSteer: [], drainedFollowUp: [] }, }; @@ -511,24 +504,6 @@ describe("runtime assistant generation", () => { expect( cancelled.storage.getCommitAttempts().some((writes) => writesOperationState(writes, "effect_pending")), ).toBe(false); - - const replaced = await createFixture(); - const replacedReady = await advanceToReady(replaced); - const ownerHookStarted = deferred(); - const releaseOwnerHook = deferred(); - replaced.hooks.on("before_request", async () => { - ownerHookStarted.resolve(); - await releaseOwnerHook.promise; - return undefined; - }); - replaced.storage.clearCommitAttempts(); - const losing = runGeneration(replaced.lane, replaced.drive, currentRun(replaced.lane), replacedReady); - await ownerHookStarted.promise; - replaced.lane.activeDrive = new Drive({ operationId }, BACKGROUND_CONTEXT); - releaseOwnerHook.resolve(); - expect(await losing).toEqual({ kind: "lost_ownership" }); - expect(replaced.faux.state.callCount).toBe(0); - expect(replaced.storage.getCommitAttempts()).toEqual([]); }); it("recovers an orphan with no frames into a zero-usage retry wait", async () => { @@ -536,9 +511,10 @@ describe("runtime assistant generation", () => { const ready = await advanceToReady(fixture); const responseEntryId = "01950000-0000-7000-8000-000000000028"; const usageId = "01950000-0000-7000-8000-000000000029"; - const pending: Extract = { - status: "effect_pending", - context: { ...ready.context, retryPolicy: { maxAttempts: 2, baseDelayMs: 1 } }, + const pending: RunAssistantEffectPendingOperation = { + ...runScopeOf(ready), + at: "run.assistant.effect_pending", + generationContext: { ...ready.generationContext, retryPolicy: { maxAttempts: 2, baseDelayMs: 1 } }, attempt: 1, responseEntryId, usageId, @@ -547,33 +523,29 @@ describe("runtime assistant generation", () => { }; await fixture.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextState: RunState = { - ...operation.state, - phase: { kind: "assistant", generation: pending }, - }; + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); return { kind: "commit", - writes: [storedValues.setValue(storedValues.operationState(operationId), nextState)], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + writes: [storedValues.setValue(storedValues.operationState(operationId), pending)], + next: { ...state, operation: { meta: operation.meta, state: pending } }, materialize: () => undefined, }; }, BACKGROUND_CONTEXT); - await recoverAssistantGeneration(fixture.lane, fixture.drive, currentRun(fixture.lane), pending); + await recoverAssistantGeneration(fixture.lane, fixture.drive, pending); const entry = (await fixture.session.getEntry(responseEntryId, BACKGROUND_CONTEXT)) as MessageEntry; expect(entry.message).toMatchObject({ api: "unknown", - provider: ready.context.configuration.model.provider, - model: ready.context.configuration.model.modelId, + provider: ready.generationContext.configuration.model.provider, + model: ready.generationContext.configuration.model.modelId, content: [], stopReason: "error", usage: { totalTokens: 0 }, }); - expect(currentRun(fixture.lane).phase).toMatchObject({ - kind: "assistant", - generation: { status: "retry_wait", nextAttempt: 2 }, + expect(currentRun(fixture.lane)).toMatchObject({ + at: "run.assistant.retry_wait", + nextAttempt: 2, }); expect(fixture.faux.state.callCount).toBe(0); await expectProjectionRestores(fixture); @@ -588,9 +560,10 @@ describe("runtime assistant generation", () => { ...fauxAssistantMessage([], { timestamp: 6 }), stopReason: "pending", }; - const pending: Extract = { - status: "effect_pending", - context: { ...ready.context, retryPolicy: { maxAttempts: 1, baseDelayMs: 1 } }, + const pending: RunAssistantEffectPendingOperation = { + ...runScopeOf(ready), + at: "run.assistant.effect_pending", + generationContext: { ...ready.generationContext, retryPolicy: { maxAttempts: 1, baseDelayMs: 1 } }, attempt: 1, responseEntryId, usageId, @@ -599,15 +572,11 @@ describe("runtime assistant generation", () => { }; await fixture.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextState: RunState = { - ...operation.state, - phase: { kind: "assistant", generation: pending }, - }; + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); return { kind: "commit", writes: [ - storedValues.setValue(storedValues.operationState(operationId), nextState), + storedValues.setValue(storedValues.operationState(operationId), pending), storedValues.appendList(storedValues.pendingAssistantFrames(operationId, responseEntryId), { type: "start", partial, @@ -628,7 +597,7 @@ describe("runtime assistant generation", () => { content: "corrected", }), ], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + next: { ...state, operation: { meta: operation.meta, state: pending } }, materialize: () => undefined, }; }, BACKGROUND_CONTEXT); @@ -638,7 +607,7 @@ describe("runtime assistant generation", () => { return undefined; }); - expect(await recoverAssistantGeneration(fixture.lane, fixture.drive, currentRun(fixture.lane), pending)).toEqual({ + expect(await recoverAssistantGeneration(fixture.lane, fixture.drive, pending)).toEqual({ kind: "continue", }); @@ -651,7 +620,7 @@ describe("runtime assistant generation", () => { stopReason: "error", usage: { input: 0, output: 0 }, }); - expect(currentRun(fixture.lane).phase).toMatchObject({ kind: "failure_drain" }); + expect(currentRun(fixture.lane).at).toBe("run.failure_drain"); expect( await fixture.session.readList( storedValues.pendingAssistantFrames(operationId, responseEntryId), @@ -669,11 +638,11 @@ describe("runtime assistant generation", () => { const fixture = await createFixture(); const ready = await advanceToReady(fixture); fixture.faux.setResponses([fauxAssistantMessage("done", { timestamp: 7 })]); - await runGeneration(fixture.lane, fixture.drive, currentRun(fixture.lane), ready); + await runGeneration(fixture.lane, fixture.drive, ready); const run = currentRun(fixture.lane); - if (run.phase.kind !== "checkpoint") throw new Error("missing terminal checkpoint"); + if (run.at !== "run.checkpoint") throw new Error("missing terminal checkpoint"); - const result = await runCheckpoint(fixture.lane, fixture.drive, run, run.phase); + const result = await runCheckpoint(fixture.lane, fixture.drive, run); expect(result).toMatchObject({ kind: "settled", diff --git a/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts b/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts index ee3c58811..12e9c6243 100644 --- a/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts +++ b/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts @@ -23,13 +23,18 @@ import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import { GatingStorage } from "../../../src/harness/session/testing/gating-storage.ts"; import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts"; -import type { - Deferred, - Generation, - LaneConfiguration, - RunState, - Session, - Write, +import { + isRunOperationState, + type LaneConfiguration, + type OperationState, + type RunAssistantReadyOperation, + type RunAssistantRetryWaitOperation, + type RunDeferredEffectPendingOperation, + type RunDeferredSuspendedOperation, + type RunOperationState, + runScopeOf, + type Session, + type Write, } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; @@ -133,29 +138,30 @@ async function createFixture( return { backend, gating, storage, session, lane, drive, models, faux, hooks, events, config }; } -function currentRun(fixture: Fixture): RunState { +function currentRun(fixture: Fixture): RunOperationState { const operation = fixture.lane.state.operation; - if (operation?.state.kind !== "run") throw new Error("fixture has no run"); + if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); return operation.state; } -async function advanceToReady(fixture: Fixture): Promise> { +async function advanceToReady(fixture: Fixture): Promise { + const starting = currentRun(fixture); + if (starting.at !== "run.starting") throw new Error("run did not start at its initial boundary"); + await startRun(fixture.lane, fixture.drive, starting); let run = currentRun(fixture); - await startRun(fixture.lane, fixture.drive, run); + if (run.at !== "run.checkpoint") throw new Error("run did not reach checkpoint"); + await runCheckpoint(fixture.lane, fixture.drive, run); run = currentRun(fixture); - if (run.phase.kind !== "checkpoint") throw new Error("run did not reach checkpoint"); - await runCheckpoint(fixture.lane, fixture.drive, run, run.phase); - run = currentRun(fixture); - if (run.phase.kind !== "assistant" || run.phase.generation.status !== "ready") { + if (run.at !== "run.assistant.ready") { throw new Error("run did not reach ready generation"); } - return run.phase.generation; + return run; } -async function replaceRunState(fixture: Fixture, nextState: RunState, extraWrites: Write[] = []): Promise { +async function replaceRunState(fixture: Fixture, nextState: OperationState, extraWrites: Write[] = []): Promise { await fixture.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("fixture has no run"); + if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); return { kind: "commit", writes: [...extraWrites, storedValues.setValue(storedValues.operationState(operationId), nextState)], @@ -168,23 +174,24 @@ async function replaceRunState(fixture: Fixture, nextState: RunState, extraWrite async function submitDeferred( fixture: Fixture, response: AssistantMessage = fauxAssistantMessage("done", { timestamp: 20 }), -): Promise> { +): Promise { fixture.faux.setResponses([response]); const ready = await advanceToReady(fixture); - await runGeneration(fixture.lane, fixture.drive, currentRun(fixture), ready); + await runGeneration(fixture.lane, fixture.drive, ready); const run = currentRun(fixture); - if (run.phase.kind !== "deferred" || run.phase.deferred.status !== "suspended") { + if (run.at !== "run.deferred.suspended") { throw new Error("initial request did not suspend"); } - return run.phase.deferred; + return run; } async function installUnknownPoll( fixture: Fixture, - suspended: Extract, -): Promise> { - const effectPending: Extract = { - status: "effect_pending", + suspended: RunDeferredSuspendedOperation, +): Promise { + const effectPending: RunDeferredEffectPendingOperation = { + ...runScopeOf(currentRun(fixture)), + at: "run.deferred.effect_pending", stepId: suspended.stepId, sourceEntryId: suspended.sourceEntryId, poll: suspended.poll + 1, @@ -193,9 +200,7 @@ async function installUnknownPoll( configuration: suspended.configuration, streamOptions: suspended.streamOptions, }; - const run = currentRun(fixture); - const nextState: RunState = { ...run, phase: { kind: "deferred", deferred: effectPending } }; - await replaceRunState(fixture, nextState, [ + await replaceRunState(fixture, effectPending, [ storedValues.appendList(storedValues.pendingAssistantFrames(operationId, effectPending.responseEntryId), { type: "text_delta", contentIndex: 0, @@ -205,10 +210,12 @@ async function installUnknownPoll( return effectPending; } -function currentDeferred(fixture: Fixture): Deferred { +function currentDeferred(fixture: Fixture): RunDeferredSuspendedOperation | RunDeferredEffectPendingOperation { const run = currentRun(fixture); - if (run.phase.kind !== "deferred") throw new Error("fixture has no deferred phase"); - return run.phase.deferred; + if (run.at !== "run.deferred.suspended" && run.at !== "run.deferred.effect_pending") { + throw new Error("fixture has no deferred phase"); + } + return run; } function installDrive(fixture: Fixture, options: { waitForRetry?: boolean; pollDeferred?: boolean }): Drive { @@ -234,16 +241,13 @@ describe("runtime assistant retry wait", () => { fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 service unavailable", timestamp: 10 }), ]); - expect(await runGeneration(fixture.lane, fixture.drive, currentRun(fixture), ready)).toEqual({ + expect(await runGeneration(fixture.lane, fixture.drive, ready)).toEqual({ kind: "continue", }); - expect(currentRun(fixture).phase).toMatchObject({ - kind: "assistant", - generation: { - status: "retry_wait", - nextAttempt: 2, - errorMessage: "503 service unavailable", - }, + expect(currentRun(fixture)).toMatchObject({ + at: "run.assistant.retry_wait", + nextAttempt: 2, + errorMessage: "503 service unavailable", }); expect(fixture.events.at(-1)).toMatchObject({ type: "retry_scheduled", attempt: 2 }); await expectProjectionRestores(fixture); @@ -254,20 +258,19 @@ describe("runtime assistant retry wait", () => { vi.setSystemTime(1_000); const fixture = await createFixture({ deferredSubmission: false }); const ready = await advanceToReady(fixture); - const retryWait: Extract = { - status: "retry_wait", - context: ready.context, + const retryWait: RunAssistantRetryWaitOperation = { + ...runScopeOf(ready), + at: "run.assistant.retry_wait", + generationContext: ready.generationContext, nextAttempt: 2, notBefore: 1_100, errorMessage: "retry", }; - const run = currentRun(fixture); - const nextState: RunState = { ...run, phase: { kind: "assistant", generation: retryWait } }; - await replaceRunState(fixture, nextState); + await replaceRunState(fixture, retryWait); fixture.storage.clearCommitAttempts(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - expect(await runGeneration(fixture.lane, fixture.drive, currentRun(fixture), retryWait)).toEqual({ + expect(await runGeneration(fixture.lane, fixture.drive, retryWait)).toEqual({ kind: "waiting", outcome: { kind: "waiting", operationId, reason: "retry", notBefore: 1_100 }, }); @@ -280,27 +283,27 @@ describe("runtime assistant retry wait", () => { vi.setSystemTime(2_000); const fixture = await createFixture({ deferredSubmission: false }); const ready = await advanceToReady(fixture); - const retryWait: Extract = { - status: "retry_wait", - context: ready.context, + const retryWait: RunAssistantRetryWaitOperation = { + ...runScopeOf(ready), + at: "run.assistant.retry_wait", + generationContext: ready.generationContext, nextAttempt: 2, notBefore: 2_000, errorMessage: "retry", }; - const run = currentRun(fixture); - await replaceRunState(fixture, { ...run, phase: { kind: "assistant", generation: retryWait } }); + await replaceRunState(fixture, retryWait); fixture.storage.clearCommitAttempts(); - expect(await runGeneration(fixture.lane, fixture.drive, currentRun(fixture), retryWait)).toEqual({ + expect(await runGeneration(fixture.lane, fixture.drive, retryWait)).toEqual({ kind: "continue", }); const next = currentRun(fixture); - if (next.phase.kind !== "assistant" || next.phase.generation.status !== "ready") { + if (next.at !== "run.assistant.ready") { throw new Error("retry did not become ready"); } expect(fixture.events.at(-1)).toMatchObject({ type: "retry_start", attempt: 2 }); fixture.faux.setResponses([fauxAssistantMessage("retried", { timestamp: 30 })]); - await runGeneration(fixture.lane, fixture.drive, next, next.phase.generation); + await runGeneration(fixture.lane, fixture.drive, next); expect(fixture.events).toContainEqual(expect.objectContaining({ type: "retry_end", attempt: 2, success: true })); await expectProjectionRestores(fixture); }); @@ -310,26 +313,26 @@ describe("runtime assistant retry wait", () => { vi.setSystemTime(3_000); const fixture = await createFixture({ deferredSubmission: false }); const ready = await advanceToReady(fixture); - const retryWait: Extract = { - status: "retry_wait", - context: ready.context, + const retryWait: RunAssistantRetryWaitOperation = { + ...runScopeOf(ready), + at: "run.assistant.retry_wait", + generationContext: ready.generationContext, nextAttempt: 2, notBefore: 3_100, errorMessage: "retry", }; - const run = currentRun(fixture); - await replaceRunState(fixture, { ...run, phase: { kind: "assistant", generation: retryWait } }); + await replaceRunState(fixture, retryWait); const drive = installDrive(fixture, { waitForRetry: true }); fixture.storage.clearCommitAttempts(); - const waiting = runGeneration(fixture.lane, drive, currentRun(fixture), retryWait); + const waiting = runGeneration(fixture.lane, drive, retryWait); await vi.advanceTimersByTimeAsync(99); expect(fixture.storage.getCommitAttempts()).toEqual([]); await vi.advanceTimersByTimeAsync(1); expect(await waiting).toEqual({ kind: "continue" }); - expect(currentRun(fixture).phase).toMatchObject({ - kind: "assistant", - generation: { status: "ready", nextAttempt: 2 }, + expect(currentRun(fixture)).toMatchObject({ + at: "run.assistant.ready", + nextAttempt: 2, }); }); }); @@ -340,7 +343,7 @@ describe("runtime deferred polling", () => { const suspended = await submitDeferred(fixture); fixture.storage.clearCommitAttempts(); - expect(await runDeferredSuspended(fixture.lane, fixture.drive, currentRun(fixture), suspended)).toEqual({ + expect(await runDeferredSuspended(fixture.lane, fixture.drive, suspended)).toEqual({ kind: "waiting", outcome: { kind: "waiting", @@ -373,7 +376,7 @@ describe("runtime deferred polling", () => { const drive = new Drive(driveOptions, BACKGROUND_CONTEXT); fixture.lane.activeDrive = drive; - expect(await runDeferred(fixture.lane, drive, currentRun(fixture), currentDeferred(fixture))).toEqual({ + expect(await runDeferred(fixture.lane, drive, currentDeferred(fixture))).toEqual({ kind: "continue", }); expect(drive.deferredPermits).toBe(0); @@ -381,11 +384,11 @@ describe("runtime deferred polling", () => { expect(hookOptions).toMatchObject({ deferred: false }); expect(fetchOptions).toMatchObject({ wait: 0 }); expect(fetchOptions?.signal).toBe(drive.gate.signal); - expect(currentDeferred(fixture)).toMatchObject({ status: "suspended", poll: 1 }); + expect(currentDeferred(fixture)).toMatchObject({ at: "run.deferred.suspended", poll: 1 }); expect(fixture.faux.state.deferredFetchCount).toBe(1); expect(fixture.events.slice(-2).map((event) => event.type)).toEqual(["turn_end", "run_suspend"]); - expect(await runDeferred(fixture.lane, drive, currentRun(fixture), currentDeferred(fixture))).toMatchObject({ + expect(await runDeferred(fixture.lane, drive, currentDeferred(fixture))).toMatchObject({ kind: "waiting", outcome: { reason: "deferred" }, }); @@ -404,16 +407,16 @@ describe("runtime deferred polling", () => { ); const drive = installDrive(fixture, { pollDeferred: true }); - expect(await runDeferredSuspended(fixture.lane, drive, currentRun(fixture), suspended)).toEqual({ + expect(await runDeferredSuspended(fixture.lane, drive, suspended)).toEqual({ kind: "continue", }); const run = currentRun(fixture); - if (run.phase.kind !== "tools") throw new Error("deferred tool response did not create a batch"); - expect(run.phase.batch).toMatchObject({ + if (run.at !== "run.tools") throw new Error("deferred tool response did not create a batch"); + expect(run.batch).toMatchObject({ turnId: `${suspended.stepId}:poll:1`, calls: [{ status: "planned", sourceIndex: 0 }], }); - expect(run.phase.batch.calls[0]?.resultEntryId.slice(0, 13)).toBe(run.latestAssistantEntryId?.slice(0, 13)); + expect(run.batch.calls[0]?.resultEntryId.slice(0, 13)).toBe(run.latestAssistantEntryId?.slice(0, 13)); expect(fixture.events.at(-1)).toMatchObject({ type: "usage" }); await expectProjectionRestores(fixture); }); @@ -428,7 +431,7 @@ describe("runtime deferred polling", () => { if (gating === undefined) throw new Error("fixture is not gated"); const drive = installDrive(fixture, { pollDeferred: true }); gating.arm(); - const polling = runDeferredSuspended(fixture.lane, drive, currentRun(fixture), suspended); + const polling = runDeferredSuspended(fixture.lane, drive, suspended); await gating.waitPending(); expect(drive.deferredPermits).toBe(1); @@ -438,7 +441,7 @@ describe("runtime deferred polling", () => { await expect.poll(() => fixture.faux.state.deferredFetchCount).toBe(1); await gating.next(2); // start frame, then response settlement expect(await polling).toEqual({ kind: "continue" }); - expect(currentRun(fixture).phase).toMatchObject({ kind: "failure_drain" }); + expect(currentRun(fixture).at).toBe("run.failure_drain"); }); it("leaves suspended state unchanged when the fresh intent is discarded", async () => { @@ -448,7 +451,7 @@ describe("runtime deferred polling", () => { if (gating === undefined) throw new Error("fixture is not gated"); const drive = installDrive(fixture, { pollDeferred: true }); gating.arm(); - const polling = runDeferredSuspended(fixture.lane, drive, currentRun(fixture), suspended); + const polling = runDeferredSuspended(fixture.lane, drive, suspended); void polling.catch(() => {}); await gating.waitPending(); @@ -457,10 +460,7 @@ describe("runtime deferred polling", () => { expect(drive.deferredPermits).toBe(1); expect(fixture.faux.state.deferredFetchCount).toBe(0); const durable = await fixture.backend.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT); - expect(durable?.value).toMatchObject({ - kind: "run", - phase: { kind: "deferred", deferred: { status: "suspended", poll: 0 } }, - }); + expect(durable?.value).toMatchObject({ at: "run.deferred.suspended", poll: 0 }); }); it("replaces an unknown poll under fresh ids at the same poll number and deletes old frames", async () => { @@ -470,7 +470,7 @@ describe("runtime deferred polling", () => { fixture.storage.clearCommitAttempts(); const noPermit = installDrive(fixture, {}); - expect(await recoverDeferredPoll(fixture.lane, noPermit, currentRun(fixture), unknown)).toMatchObject({ + expect(await recoverDeferredPoll(fixture.lane, noPermit, unknown)).toMatchObject({ kind: "waiting", outcome: { reason: "deferred" }, }); @@ -484,7 +484,7 @@ describe("runtime deferred polling", () => { expect(fixture.faux.state.deferredFetchCount).toBe(0); const replacement = installDrive(fixture, { pollDeferred: true }); - expect(await recoverDeferredPoll(fixture.lane, replacement, currentRun(fixture), unknown)).toEqual({ + expect(await recoverDeferredPoll(fixture.lane, replacement, unknown)).toEqual({ kind: "continue", }); const intent = fixture.storage @@ -507,15 +507,10 @@ describe("runtime deferred polling", () => { ); expect(intentState).toMatchObject({ value: { - phase: { - kind: "deferred", - deferred: { - status: "effect_pending", - poll: unknown.poll, - responseEntryId: expect.not.stringMatching(unknown.responseEntryId), - usageId: expect.not.stringMatching(unknown.usageId), - }, - }, + at: "run.deferred.effect_pending", + poll: unknown.poll, + responseEntryId: expect.not.stringMatching(unknown.responseEntryId), + usageId: expect.not.stringMatching(unknown.usageId), }, }); expect(await fixture.session.getEntry(unknown.responseEntryId, BACKGROUND_CONTEXT)).toBeUndefined(); @@ -529,7 +524,7 @@ describe("runtime deferred polling", () => { BACKGROUND_CONTEXT, ), ).toEqual([]); - expect(currentRun(fixture).phase).toMatchObject({ kind: "checkpoint" }); + expect(currentRun(fixture).at).toBe("run.checkpoint"); expect(fixture.events.some((event) => "recovery" in event && event.recovery === true)).toBe(true); await expectProjectionRestores(fixture); }); @@ -542,13 +537,13 @@ describe("runtime deferred polling", () => { fixture.storage.clearCommitAttempts(); const drive = installDrive(fixture, { pollDeferred: true }); - expect(await recoverDeferredPoll(fixture.lane, drive, currentRun(fixture), unknown)).toEqual({ + expect(await recoverDeferredPoll(fixture.lane, drive, unknown)).toEqual({ kind: "continue", }); expect(drive.deferredPermits).toBe(1); expect(fixture.faux.state.deferredFetchCount).toBe(0); - expect(currentRun(fixture).phase).toMatchObject({ - kind: "failure_drain", + expect(currentRun(fixture)).toMatchObject({ + at: "run.failure_drain", error: { code: "model_unavailable" }, provenance: { kind: "configuration" }, }); diff --git a/packages/agent/test/harness/runtime/drive-terminal.test.ts b/packages/agent/test/harness/runtime/drive-terminal.test.ts index 316d453cc..5445048b0 100644 --- a/packages/agent/test/harness/runtime/drive-terminal.test.ts +++ b/packages/agent/test/harness/runtime/drive-terminal.test.ts @@ -6,13 +6,15 @@ import { insertEntry } from "../../../src/harness/session/commit.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import type { - CompactionState, + CompactionDecidingOperation, LaneConfiguration, LaneLastResult, - NavigationState, + NavigationReadyToCommitOperation, OperationMeta, OperationState, - RunState, + RunAssistantEffectPendingOperation, + RunScope, + RunToolsOperation, Session, Write, } from "../../../src/harness/session/types.ts"; @@ -25,9 +27,8 @@ const configuration: LaneConfiguration = { activeToolNames: [], }; -function runState(phase: RunState["phase"]): RunState { +function runScope(): RunScope { return { - kind: "run", control: { status: "running" }, settings: { compaction: { enabled: true, reserveTokens: 1_000, keepRecentTokens: 2_000 }, @@ -35,7 +36,6 @@ function runState(phase: RunState["phase"]): RunState { followUpMode: "all", toolExecution: "parallel", }, - phase, inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; @@ -43,11 +43,11 @@ function runState(phase: RunState["phase"]): RunState { function meta(operationId: string, state: OperationState): OperationMeta { const intent: OperationMeta["intent"] = - state.kind === "run" - ? { kind: "run", promptEntryIds: [] } - : state.kind === "compaction" - ? { kind: "compaction" } - : { kind: "navigation", targetId: state.targetId, summarize: state.summarize }; + state.at === "compaction.deciding" + ? { kind: "compaction" } + : state.at === "navigation.ready_to_commit" + ? { kind: "navigation", targetId: state.targetId, summarize: false } + : { kind: "run", promptEntryIds: [] }; return { operationId, lane: "main", sourceTipId: null, startedAt: 1, intent }; } @@ -101,26 +101,22 @@ describe("runtime terminal cleanup mechanics", () => { const { session } = await createSession(); const operationId = "run"; const responseEntryId = "response"; - const state: RunState = { - ...runState({ - kind: "assistant", - generation: { - status: "effect_pending", - context: { - stepId: "step", - triggerEntryId: "trigger", - configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 1 }, - overflowRecoveryUsed: false, - }, - attempt: 1, - responseEntryId, - usageId: "usage", - intendedOutputLimit: 100, - contextWindow: 1_000, - }, - }), + const state: RunAssistantEffectPendingOperation = { + ...runScope(), + at: "run.assistant.effect_pending", + generationContext: { + stepId: "step", + triggerEntryId: "trigger", + configuration, + streamOptions: {}, + retryPolicy: { maxAttempts: 2, baseDelayMs: 1 }, + overflowRecoveryUsed: false, + }, + attempt: 1, + responseEntryId, + usageId: "usage", + intendedOutputLimit: 100, + contextWindow: 1_000, control: { status: "cancel_requested", requestedAt: 2, @@ -182,8 +178,9 @@ describe("runtime terminal cleanup mechanics", () => { it("deletes staged tool outcomes and leaves completed results alone", async () => { const { session } = await createSession(); const operationId = "tools"; - const state = runState({ - kind: "tools", + const state: RunToolsOperation = { + ...runScope(), + at: "run.tools", batch: { assistantEntryId: "assistant", configuration, @@ -193,7 +190,7 @@ describe("runtime terminal cleanup mechanics", () => { { status: "completed", sourceIndex: 1, resultEntryId: "placed", terminate: false }, ], }, - }); + }; await seedLeftovers(session, operationId, state); await commit(session, [ storedValues.setValue(storedValues.pendingEntry("staged"), { @@ -219,20 +216,18 @@ describe("runtime terminal cleanup mechanics", () => { [ "compaction", { - kind: "compaction", + at: "compaction.deciding", control: { status: "running" }, - structural: { status: "deciding", taskId: "task" }, - } satisfies CompactionState, + taskId: "task", + } satisfies CompactionDecidingOperation, ], [ "navigation", { - kind: "navigation", + at: "navigation.ready_to_commit", control: { status: "running" }, targetId: null, - summarize: false, - phase: { kind: "ready_to_commit" }, - } satisfies NavigationState, + } satisfies NavigationReadyToCommitOperation, ], ] as const)("defensively deletes leftover %s operation families", async (operationId, state) => { const { session } = await createSession(); diff --git a/packages/agent/test/harness/runtime/drive-tools.test.ts b/packages/agent/test/harness/runtime/drive-tools.test.ts index 3e37e0641..af97cbc6d 100644 --- a/packages/agent/test/harness/runtime/drive-tools.test.ts +++ b/packages/agent/test/harness/runtime/drive-tools.test.ts @@ -18,7 +18,15 @@ import { type Config, Drive } from "../../../src/harness/runtime/types.ts"; import { insertEntry } from "../../../src/harness/session/commit.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; -import type { LaneConfiguration, RunState, Session, ToolCall, Write } from "../../../src/harness/session/types.ts"; +import { + isRunOperationState, + type LaneConfiguration, + type RunOperationState, + type RunToolsOperation, + type Session, + type ToolCall, + type Write, +} from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; import type { AgentHarnessTool, @@ -51,7 +59,7 @@ interface FixtureOptions { operationId: string; assistantEntryId: string; resultEntryIds: string[]; - run: RunState; + run: RunToolsOperation; }) => Write[]; toolContext?: Config["toolContext"]; cancelled?: boolean; @@ -133,8 +141,8 @@ async function createFixture(options: FixtureOptions): Promise { sourceIndex: index, resultEntryId, })); - const run: RunState = { - kind: "run", + const run: RunToolsOperation = { + at: "run.tools", control: options.cancelled ? { status: "cancel_requested", requestedAt: 30, drainedSteer: [], drainedFollowUp: [] } : { status: "running" }, @@ -144,10 +152,7 @@ async function createFixture(options: FixtureOptions): Promise { followUpMode: "all", toolExecution: options.mode ?? "parallel", }, - phase: { - kind: "tools", - batch: { assistantEntryId, configuration, turnId: "turn-1", calls }, - }, + batch: { assistantEntryId, configuration, turnId: "turn-1", calls }, inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: assistantEntryId, }; @@ -211,22 +216,22 @@ async function createFixture(options: FixtureOptions): Promise { return { session, lane, drive, hooks, events, assistantEntryId, resultEntryIds, operationId, config }; } -function currentRun(fixture: Fixture): RunState { +function currentRun(fixture: Fixture): RunOperationState { const operation = fixture.lane.state.operation; - if (operation?.state.kind !== "run") throw new Error("fixture has no run"); + if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); return operation.state; } function currentCalls(fixture: Fixture): ToolCall[] { const run = currentRun(fixture); - if (run.phase.kind !== "tools") return []; - return run.phase.batch.calls; + if (run.at !== "run.tools") return []; + return run.batch.calls; } async function driveTools(fixture: Fixture) { const run = currentRun(fixture); - if (run.phase.kind !== "tools") throw new Error("fixture has no tool batch"); - return runTools(fixture.lane, fixture.drive, run, run.phase.batch); + if (run.at !== "run.tools") throw new Error("fixture has no tool batch"); + return runTools(fixture.lane, fixture.drive, run); } async function expectProjectionRestores(fixture: Fixture): Promise { @@ -297,8 +302,8 @@ describe("durable tool batch", () => { }); expect(contextResolutions).toBe(1); expect(checkpointSeenByAfterHook).toBe(true); - expect(currentRun(fixture).phase).toMatchObject({ - kind: "checkpoint", + expect(currentRun(fixture)).toMatchObject({ + at: "run.checkpoint", continuation: { kind: "need_assistant" }, }); expect(fixture.lane.state.configuration.activeToolNames).toEqual(["first", "second", "introduced"]); @@ -444,8 +449,9 @@ describe("durable tool batch", () => { expect(beforeTool).not.toHaveBeenCalled(); expect(afterTool).not.toHaveBeenCalled(); expect(currentRun(fixture)).toMatchObject({ + at: "run.checkpoint", control: { status: "cancel_requested" }, - phase: { kind: "checkpoint", continuation: { kind: "need_assistant" } }, + continuation: { kind: "need_assistant" }, }); const pending = await fixture.session.getEntry(fixture.resultEntryIds[1]!, BACKGROUND_CONTEXT); expect( @@ -492,8 +498,8 @@ describe("durable tool batch", () => { await driveTools(fixture); expect(execute).not.toHaveBeenCalled(); expect(context).not.toHaveBeenCalled(); - expect(currentRun(fixture).phase).toMatchObject({ - kind: "checkpoint", + expect(currentRun(fixture)).toMatchObject({ + at: "run.checkpoint", continuation: { kind: "may_finish", includeFinalAssistant: false }, }); expect(fixture.lane.state.configuration.activeToolNames).toEqual(["ready", "later"]); @@ -574,32 +580,6 @@ describe("durable tool batch", () => { expect(afterStarted).toBe(true); }); - it("fences outcome staging after exact Drive ownership changes", async () => { - const finish = deferred(); - const started = deferred(); - const fixture = await createFixture({ - calls: [{ name: "owned" }], - tools: [ - tool("owned", async () => { - started.resolve(); - await finish.promise; - return { content: [{ type: "text", text: "done" }], details: {} }; - }), - ], - mode: "sequential", - }); - const running = driveTools(fixture); - await started.promise; - fixture.lane.activeDrive = new Drive({ operationId: fixture.operationId }, BACKGROUND_CONTEXT); - finish.resolve(); - - expect(await running).toEqual({ kind: "lost_ownership" }); - expect(currentCalls(fixture)[0]?.status).toBe("effect_pending"); - expect( - await fixture.session.getValue(storedValues.pendingEntry(fixture.resultEntryIds[0]!), BACKGROUND_CONTEXT), - ).toBeUndefined(); - }); - it("does not stage a cancelled outcome before cancellation is durable", async () => { const execute = vi.fn(async () => ({ content: [], details: {} })); const fixture = await createFixture({ @@ -618,8 +598,8 @@ describe("durable tool batch", () => { ).toBeUndefined(); await fixture.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextRun: RunState = { + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + const nextRun: RunOperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: 40, drainedSteer: [], drainedFollowUp: [] }, }; @@ -672,8 +652,8 @@ describe("durable tool batch", () => { fixture.drive.beginAbort(cancellation.promise); await fixture.lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextRun: RunState = { + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + const nextRun: RunOperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: 40, drainedSteer: [], drainedFollowUp: [] }, }; diff --git a/packages/agent/test/harness/runtime/lane.test.ts b/packages/agent/test/harness/runtime/lane.test.ts index e5082b260..c0fa58012 100644 --- a/packages/agent/test/harness/runtime/lane.test.ts +++ b/packages/agent/test/harness/runtime/lane.test.ts @@ -8,7 +8,6 @@ import { HookRegistry } from "../../../src/harness/hooks.ts"; import { convertToLlm } from "../../../src/harness/messages.ts"; import { Lane } from "../../../src/harness/runtime/lane.ts"; import { restoreLane } from "../../../src/harness/runtime/restore.ts"; -import { Drive } from "../../../src/harness/runtime/types.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import type { LaneConfiguration, Session } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; @@ -247,87 +246,6 @@ describe("runtime Lane commands", () => { ); }); - it("declines an owned commit when its exact drive is replaced while planning", async () => { - const { lane, session } = await createLane(); - const admission = await lane.accept({ kind: "prompt", prompt: "start" }, BACKGROUND_CONTEXT); - if (!admission.ok) throw admission.error; - const drive = new Drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT); - lane.activeDrive = drive; - const plannerStarted = deferred(); - const releasePlanner = deferred(); - - const command = lane.commandDriveOwned( - drive, - async (state) => { - plannerStarted.resolve(); - await releasePlanner.promise; - const configuration: LaneConfiguration = { ...state.configuration, thinkingLevel: "high" }; - return { - kind: "commit", - writes: [storedValues.setValue(storedValues.laneConfig("main"), configuration)], - next: { ...state, configuration }, - materialize: () => "committed" as const, - }; - }, - BACKGROUND_CONTEXT, - ); - await plannerStarted.promise; - lane.activeDrive = new Drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT); - releasePlanner.resolve(); - - expect(await command).toEqual({ kind: "lost_ownership" }); - expect(lane.state.configuration.thinkingLevel).toBe("off"); - expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value.thinkingLevel).toBe( - "off", - ); - }); - - it("finishes a commit admitted before exact drive replacement", async () => { - const { lane, session, storage } = await createLane(); - const admission = await lane.accept({ kind: "prompt", prompt: "start" }, BACKGROUND_CONTEXT); - if (!admission.ok) throw admission.error; - const drive = new Drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT); - lane.activeDrive = drive; - const commitStarted = deferred(); - const releaseCommit = deferred(); - storage.beforeNextCommit = async () => { - commitStarted.resolve(); - await releaseCommit.promise; - }; - - const command = lane.commandDriveOwned( - drive, - (state) => { - const configuration: LaneConfiguration = { ...state.configuration, thinkingLevel: "high" }; - return { - kind: "commit", - writes: [storedValues.setValue(storedValues.laneConfig("main"), configuration)], - next: { ...state, configuration }, - materialize: () => "committed" as const, - }; - }, - BACKGROUND_CONTEXT, - ); - await commitStarted.promise; - const replacement = new Drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT); - let publishedBeforeReplacement = false; - const replacing = lane.command((state) => { - publishedBeforeReplacement = state === lane.state && state.configuration.thinkingLevel === "high"; - lane.activeDrive = replacement; - return { kind: "return", result: undefined }; - }, BACKGROUND_CONTEXT); - releaseCommit.resolve(); - - expect(await command).toBe("committed"); - await replacing; - expect(publishedBeforeReplacement).toBe(true); - expect(lane.activeDrive).toBe(replacement); - expect(lane.state.configuration.thinkingLevel).toBe("high"); - expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value.thinkingLevel).toBe( - "high", - ); - }); - it("rejects work after sealing while an admitted commit finishes", async () => { const { lane, storage } = await createLane(); const commitStarted = deferred(); diff --git a/packages/agent/test/harness/runtime/progress.test.ts b/packages/agent/test/harness/runtime/progress.test.ts index f70d59d81..aa1292e1c 100644 --- a/packages/agent/test/harness/runtime/progress.test.ts +++ b/packages/agent/test/harness/runtime/progress.test.ts @@ -9,7 +9,15 @@ import { openFrameProgress, openToolProgress } from "../../../src/harness/runtim import { type Config, Drive, type LaneState as RuntimeLaneState } from "../../../src/harness/runtime/types.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; -import type { RunState, Session, Write } from "../../../src/harness/session/types.ts"; +import { + isRunOperationState, + type OperationState, + type RunAssistantEffectPendingOperation, + type RunCheckpointOperation, + runScopeOf, + type Session, + type Write, +} from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; import { deferred } from "./test-utils.ts"; @@ -55,24 +63,42 @@ function unusedWatch(): WatchHandle { throw new Error("watch is not used by progress tests"); } -function runState(phase: RunState["phase"]): RunState { +function runScope() { return { - kind: "run", - control: { status: "running" }, + control: { status: "running" as const }, settings: { compaction: DEFAULT_COMPACTION_SETTINGS, - steeringMode: "all", - followUpMode: "all", - toolExecution: "parallel", + steeringMode: "all" as const, + followUpMode: "all" as const, + toolExecution: "parallel" as const, }, - phase, inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; } +function assistantEffectPending(responseEntryId: string): RunAssistantEffectPendingOperation { + return { + ...runScope(), + at: "run.assistant.effect_pending", + generationContext: { + stepId: "step", + triggerEntryId: "trigger", + configuration, + streamOptions: {}, + retryPolicy: { maxAttempts: 2, baseDelayMs: 1 }, + overflowRecoveryUsed: false, + }, + attempt: 1, + responseEntryId, + usageId: "usage", + intendedOutputLimit: 100, + contextWindow: 1_000, + }; +} + async function createFixture( - state: RunState, + state: OperationState, storage = new FailingStorage(), ): Promise<{ lane: Lane; drive: Drive; storage: FailingStorage }> { const session = new StorageBackedSession( @@ -145,7 +171,7 @@ describe("runtime active drive ownership", () => { expect("beginAbort" in failed.gate).toBe(false); }); - it("owns installer cancellation, pass context, policy, and gate control", () => { + it("strips invocation cancellation from pass context and owns policy and gate control", () => { const controller = new AbortController(); const drive = new Drive( { operationId, waitForRetry: true, pollDeferred: true }, @@ -153,7 +179,6 @@ describe("runtime active drive ownership", () => { ); expect(drive.operationId).toBe(operationId); - expect(drive.installerSignal).toBe(controller.signal); expect(drive.context.abortSignal).toBeUndefined(); expect(drive.waitForRetry).toBe(true); expect(drive.deferredPermits).toBe(1); @@ -168,27 +193,7 @@ describe("runtime active drive ownership", () => { describe("runtime progress channels", () => { it("enqueues assistant frames in order, seals admission, and exposes the settlement delete", async () => { const responseEntryId = "response"; - const { lane, drive } = await createFixture( - runState({ - kind: "assistant", - generation: { - status: "effect_pending", - context: { - stepId: "step", - triggerEntryId: "trigger", - configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 1 }, - overflowRecoveryUsed: false, - }, - attempt: 1, - responseEntryId, - usageId: "usage", - intendedOutputLimit: 100, - contextWindow: 1_000, - }, - }), - ); + const { lane, drive } = await createFixture(assistantEffectPending(responseEntryId)); const progress = openFrameProgress(lane, drive, responseEntryId); const frames: AssistantMessageFrame[] = [ { type: "text_delta", contentIndex: 0, delta: "a" }, @@ -217,27 +222,7 @@ describe("runtime progress channels", () => { it("declines a queued frame after the authoritative projection leaves its phase without control reads", async () => { const responseEntryId = "response"; - const { lane, drive, storage } = await createFixture( - runState({ - kind: "assistant", - generation: { - status: "effect_pending", - context: { - stepId: "step", - triggerEntryId: "trigger", - configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 1 }, - overflowRecoveryUsed: false, - }, - attempt: 1, - responseEntryId, - usageId: "usage", - intendedOutputLimit: 100, - contextWindow: 1_000, - }, - }), - ); + const { lane, drive, storage } = await createFixture(assistantEffectPending(responseEntryId)); const progress = openFrameProgress(lane, drive, responseEntryId); const commitStarted = deferred(); const releaseCommit = deferred(); @@ -248,14 +233,12 @@ describe("runtime progress channels", () => { const getValue = vi.spyOn(storage, "getValue"); const moving = lane.command((state) => { const operation = state.operation; - if (operation?.state.kind !== "run") throw new Error("missing run"); - const nextState: RunState = { - ...operation.state, - phase: { - kind: "checkpoint", - continuation: { kind: "may_finish", includeFinalAssistant: true }, - triggerEntryId: responseEntryId, - }, + if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + const nextState: RunCheckpointOperation = { + ...runScopeOf(operation.state), + at: "run.checkpoint", + continuation: { kind: "may_finish", includeFinalAssistant: true }, + triggerEntryId: responseEntryId, }; return { kind: "commit", @@ -282,27 +265,7 @@ describe("runtime progress channels", () => { it("declines a queued frame after terminal projection publication", async () => { const responseEntryId = "response"; - const { lane, drive, storage } = await createFixture( - runState({ - kind: "assistant", - generation: { - status: "effect_pending", - context: { - stepId: "step", - triggerEntryId: "trigger", - configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 1 }, - overflowRecoveryUsed: false, - }, - attempt: 1, - responseEntryId, - usageId: "usage", - intendedOutputLimit: 100, - contextWindow: 1_000, - }, - }), - ); + const { lane, drive, storage } = await createFixture(assistantEffectPending(responseEntryId)); const progress = openFrameProgress(lane, drive, responseEntryId); const commitStarted = deferred(); const releaseCommit = deferred(); @@ -343,19 +306,18 @@ describe("runtime progress channels", () => { expect(getValue).not.toHaveBeenCalled(); }); - it("replaces tool checkpoints and fences writes after owner identity changes", async () => { + it("replaces tool checkpoints in invocation order", async () => { const invocationId = "result"; - const { lane, drive } = await createFixture( - runState({ - kind: "tools", - batch: { - assistantEntryId: "assistant", - configuration, - turnId: "turn", - calls: [{ status: "effect_pending", sourceIndex: 0, resultEntryId: invocationId, replay: "safe" }], - }, - }), - ); + const { lane, drive } = await createFixture({ + ...runScope(), + at: "run.tools", + batch: { + assistantEntryId: "assistant", + configuration, + turnId: "turn", + calls: [{ status: "effect_pending", sourceIndex: 0, resultEntryId: invocationId, replay: "safe" }], + }, + } satisfies OperationState); const progress = openToolProgress(lane, drive, "turn", 0, invocationId); progress.write({ content: [{ type: "text", text: "first" }], details: {} }); progress.write({ content: [{ type: "text", text: "second" }], details: {} }); @@ -364,39 +326,11 @@ describe("runtime progress channels", () => { (await lane.session.getValue(storedValues.pendingToolOutput(operationId, invocationId), BACKGROUND_CONTEXT)) ?.value.content, ).toEqual([{ type: "text", text: "second" }]); - - lane.activeDrive = new Drive({ operationId }, BACKGROUND_CONTEXT); - progress.write({ content: [{ type: "text", text: "stale" }], details: {} }); - await progress.drain(); - expect( - (await lane.session.getValue(storedValues.pendingToolOutput(operationId, invocationId), BACKGROUND_CONTEXT)) - ?.value.content, - ).toEqual([{ type: "text", text: "second" }]); }); it("retains the rejecting write promise so drain propagates commit failure", async () => { const responseEntryId = "response"; - const { lane, drive, storage } = await createFixture( - runState({ - kind: "assistant", - generation: { - status: "effect_pending", - context: { - stepId: "step", - triggerEntryId: "trigger", - configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 1 }, - overflowRecoveryUsed: false, - }, - attempt: 1, - responseEntryId, - usageId: "usage", - intendedOutputLimit: 100, - contextWindow: 1_000, - }, - }), - ); + const { lane, drive, storage } = await createFixture(assistantEffectPending(responseEntryId)); const failure = new Error("frame commit failed"); storage.failure = failure; const progress = openFrameProgress(lane, drive, responseEntryId); diff --git a/packages/agent/test/harness/runtime/restore.test.ts b/packages/agent/test/harness/runtime/restore.test.ts index af6e354ba..05861c5ed 100644 --- a/packages/agent/test/harness/runtime/restore.test.ts +++ b/packages/agent/test/harness/runtime/restore.test.ts @@ -3,7 +3,12 @@ import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/com import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; import { restoreLane, restoreSession } from "../../../src/harness/runtime/restore.ts"; import { MemorySessionRepo, MemoryStorage } from "../../../src/harness/session/memory.ts"; -import type { LaneConfiguration, OperationMeta, RunState, Session } from "../../../src/harness/session/types.ts"; +import type { + LaneConfiguration, + OperationMeta, + RunCheckpointOperation, + Session, +} from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; const repos: MemorySessionRepo[] = []; @@ -35,9 +40,9 @@ async function createSession(): Promise { return session; } -function runState(triggerEntryId: string): RunState { +function runState(triggerEntryId: string): RunCheckpointOperation { return { - kind: "run", + at: "run.checkpoint", control: { status: "running" }, settings: { compaction: DEFAULT_COMPACTION_SETTINGS, @@ -45,11 +50,8 @@ function runState(triggerEntryId: string): RunState { followUpMode: "all", toolExecution: "parallel", }, - phase: { - kind: "checkpoint", - continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, - triggerEntryId, - }, + continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, + triggerEntryId, inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; diff --git a/packages/agent/test/harness/runtime/watch.test.ts b/packages/agent/test/harness/runtime/watch.test.ts index f159b5c3b..1a7e8c103 100644 --- a/packages/agent/test/harness/runtime/watch.test.ts +++ b/packages/agent/test/harness/runtime/watch.test.ts @@ -10,8 +10,8 @@ import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import type { OperationMeta, - RunPhase, - RunState, + OperationState, + RunScope, Session, StorageBranchScan, Write, @@ -54,9 +54,8 @@ async function commit(session: Session, writes: Write[]): Promise { await session.mutate((mutator) => mutator.commit(writes, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); } -function runState(phase: RunPhase, control: RunState["control"] = { status: "running" }): RunState { +function runScope(control: RunScope["control"] = { status: "running" }): RunScope { return { - kind: "run", control, settings: { compaction: DEFAULT_COMPACTION_SETTINGS, @@ -64,7 +63,6 @@ function runState(phase: RunPhase, control: RunState["control"] = { status: "run followUpMode: "all", toolExecution: "parallel", }, - phase, inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; @@ -161,26 +159,21 @@ describe("runtime lane watch", () => { payload: { role: "user" as const, content, timestamp: 1 }, }); const operationId = session.idGenerator.next(); - const state = runState( - { - kind: "deferred", - deferred: { - status: "suspended", - stepId: "step", - sourceEntryId: "source", - poll: 3, - configuration, - streamOptions: {}, - }, - }, - { + const state: OperationState = { + ...runScope({ status: "cancel_requested", requestedAt: 2, drainedSteer: [ids.drainedSteer], drainedFollowUp: [ids.drainedFollow], - }, - ); - state.inbox = { steer: [ids.steer], followUp: [ids.follow], writes: [ids.write] }; + }), + at: "run.deferred.suspended", + stepId: "step", + sourceEntryId: "source", + poll: 3, + configuration, + streamOptions: {}, + inbox: { steer: [ids.steer], followUp: [ids.follow], writes: [ids.write] }, + }; const meta: OperationMeta = { operationId, lane: "main", @@ -238,28 +231,23 @@ describe("runtime lane watch", () => { startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }), - storedValues.setValue( - storedValues.operationState(operationId), - runState({ - kind: "assistant", - generation: { - status: "effect_pending", - context: { - stepId: "step", - triggerEntryId: "trigger", - configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 0 }, - overflowRecoveryUsed: false, - }, - attempt: 1, - responseEntryId: "response-without-frames", - usageId: "usage", - intendedOutputLimit: 100, - contextWindow: 1000, - }, - }), - ), + storedValues.setValue(storedValues.operationState(operationId), { + ...runScope(), + at: "run.assistant.effect_pending", + generationContext: { + stepId: "step", + triggerEntryId: "trigger", + configuration, + streamOptions: {}, + retryPolicy: { maxAttempts: 2, baseDelayMs: 0 }, + overflowRecoveryUsed: false, + }, + attempt: 1, + responseEntryId: "response-without-frames", + usageId: "usage", + intendedOutputLimit: 100, + contextWindow: 1000, + }), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, pendingNextRun: [], @@ -290,28 +278,23 @@ describe("runtime lane watch", () => { startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }), - storedValues.setValue( - storedValues.operationState(frameOperationId), - runState({ - kind: "assistant", - generation: { - status: "effect_pending", - context: { - stepId: "step", - triggerEntryId: "trigger", - configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 0 }, - overflowRecoveryUsed: false, - }, - attempt: 1, - responseEntryId: "response", - usageId: "usage", - intendedOutputLimit: 100, - contextWindow: 1000, - }, - }), - ), + storedValues.setValue(storedValues.operationState(frameOperationId), { + ...runScope(), + at: "run.assistant.effect_pending", + generationContext: { + stepId: "step", + triggerEntryId: "trigger", + configuration, + streamOptions: {}, + retryPolicy: { maxAttempts: 2, baseDelayMs: 0 }, + overflowRecoveryUsed: false, + }, + attempt: 1, + responseEntryId: "response", + usageId: "usage", + intendedOutputLimit: 100, + contextWindow: 1000, + }), ...frames.map((frame) => storedValues.appendList(storedValues.pendingAssistantFrames(frameOperationId, "response"), frame), ), @@ -341,28 +324,26 @@ describe("runtime lane watch", () => { startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }), - storedValues.setValue( - storedValues.operationState(toolOperationId), - runState({ - kind: "tools", - batch: { - assistantEntryId: "assistant", - configuration, - turnId: "turn", - calls: [ - { status: "planned", sourceIndex: 1, resultEntryId: "planned" }, - { status: "effect_pending", sourceIndex: 1, resultEntryId: "result", replay: "safe" }, - { - status: "effect_pending", - sourceIndex: 2, - resultEntryId: "without-checkpoint", - replay: "never", - }, - { status: "outcome_ready", sourceIndex: 1, resultEntryId: "ready", terminate: false }, - ], - }, - }), - ), + storedValues.setValue(storedValues.operationState(toolOperationId), { + ...runScope(), + at: "run.tools", + batch: { + assistantEntryId: "assistant", + configuration, + turnId: "turn", + calls: [ + { status: "planned", sourceIndex: 1, resultEntryId: "planned" }, + { status: "effect_pending", sourceIndex: 1, resultEntryId: "result", replay: "safe" }, + { + status: "effect_pending", + sourceIndex: 2, + resultEntryId: "without-checkpoint", + replay: "never", + }, + { status: "outcome_ready", sourceIndex: 1, resultEntryId: "ready", terminate: false }, + ], + }, + }), storedValues.setValue(storedValues.operationToolArgs(toolOperationId, "turn", 1), { path: "file" }), storedValues.setValue(storedValues.operationToolArgs(toolOperationId, "turn", 2), { path: "output" }), storedValues.setValue(storedValues.pendingToolOutput(toolOperationId, "result"), { diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index 2bb63eedb..55ac6df58 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -13,19 +13,17 @@ import type { Branch, BranchScan, CancelQueuedResult, - CheckpointPhase, - CompactionState, + CheckpointData, Context, Control, CustomEntry, - Deferred, DriveOptions, DriveOutcome, DriveResult, Entry, EntryProjector, EntryWrite, - Generation, + FailureProvenance, GenerationContext, HarnessEvent, HookHandler, @@ -36,15 +34,14 @@ import type { LaneExecutionInfo, LaneLastResult, LaneSnapshot, - NavigationState, NewEntry, OperationAdmissionResult, + OperationAt, OperationMeta, OperationRequest, OperationState, - RunPhase, RunResult, - RunState, + RunScope, SearchQuery, Session, SessionCreateOptions, @@ -60,9 +57,7 @@ import type { SettledAssistantMessage, Storage, StorageBranchScan, - StructuralDecision, SummaryContext, - SummaryGeneration, ToolCall, UsageRow, UsageWrite, @@ -96,34 +91,68 @@ const summaryContext = { reason: "manual", } satisfies SummaryContext; const checkpoint = { - kind: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId: "trigger", -} satisfies CheckpointPhase; +} satisfies CheckpointData; const runningControl = { status: "running" } satisfies Control; -const generations = [ - { status: "ready", context: generationContext, nextAttempt: 1 }, - { - status: "effect_pending", - context: generationContext, - attempt: 1, - responseEntryId: "response", - usageId: "usage", - intendedOutputLimit: 4096, - contextWindow: 128000, - }, - { status: "retry_wait", context: generationContext, nextAttempt: 2, notBefore: 10, errorMessage: "retry" }, -] satisfies Generation[]; const toolCalls = [ { status: "planned", sourceIndex: 0, resultEntryId: "result-0" }, { status: "effect_pending", sourceIndex: 1, resultEntryId: "result-1", replay: "safe" }, { status: "outcome_ready", sourceIndex: 2, resultEntryId: "result-2", terminate: true }, { status: "completed", sourceIndex: 3, resultEntryId: "result-3", terminate: false }, ] satisfies ToolCall[]; -const deferredStates = [ + +const provenances = [ + { kind: "response", entryId: "response" }, + { kind: "structural", taskId: "task" }, + { kind: "configuration" }, +] satisfies FailureProvenance[]; + +const runScope = { + control: runningControl, + settings: { + compaction: { enabled: true, reserveTokens: 1000, keepRecentTokens: 2000 }, + steeringMode: "all", + followUpMode: "one-at-a-time", + toolExecution: "parallel", + }, + inbox: { steer: [], followUp: [], writes: [] }, + latestAssistantEntryId: null, +} satisfies RunScope; + +const runState = { ...runScope, at: "run.starting" } satisfies OperationState; +// One value fixture per flat leaf; `satisfies OperationState[]` keeps every discriminant checked. +const operationStates = [ + runState, + { ...runScope, at: "run.checkpoint", ...checkpoint }, + { ...runScope, at: "run.assistant.ready", generationContext, nextAttempt: 1 }, { - status: "suspended", + ...runScope, + at: "run.assistant.effect_pending", + generationContext, + attempt: 1, + responseEntryId: "response", + usageId: "usage", + intendedOutputLimit: 4096, + contextWindow: 128000, + }, + { + ...runScope, + at: "run.assistant.retry_wait", + generationContext, + nextAttempt: 2, + notBefore: 10, + errorMessage: "retry", + }, + { + ...runScope, + at: "run.tools", + batch: { assistantEntryId: "assistant", configuration, turnId: "turn", calls: toolCalls }, + }, + { + ...runScope, + at: "run.deferred.suspended", stepId: "step", sourceEntryId: "source", poll: 0, @@ -131,7 +160,8 @@ const deferredStates = [ streamOptions: {}, }, { - status: "effect_pending", + ...runScope, + at: "run.deferred.effect_pending", stepId: "step", sourceEntryId: "source", poll: 1, @@ -140,99 +170,73 @@ const deferredStates = [ configuration, streamOptions: {}, }, -] satisfies Deferred[]; -const summaryGenerations = [ - { status: "ready", context: summaryContext, nextAttempt: 1 }, + { ...runScope, at: "run.compaction.deciding", reason: "threshold", resumeAfter: checkpoint, taskId: "task" }, { - status: "effect_pending", - context: summaryContext, + ...runScope, + at: "run.compaction.ready", + reason: "threshold", + resumeAfter: checkpoint, + summaryContext, + nextAttempt: 1, + }, + { + ...runScope, + at: "run.compaction.effect_pending", + reason: "overflow", + resumeAfter: checkpoint, + summaryContext, attempt: 1, request: { index: 0, usageId: "usage" }, usageIds: [], }, - { status: "retry_wait", context: summaryContext, nextAttempt: 2, notBefore: 10, errorMessage: "retry" }, -] satisfies SummaryGeneration[]; -const structuralDecisions = [ - { status: "deciding", taskId: "task" }, - { status: "generating", taskId: "task", generation: summaryGenerations[0] }, -] satisfies StructuralDecision[]; - -const runPhases: RunPhase[] = [ - { kind: "starting" }, - checkpoint, - { kind: "assistant", generation: generations[0] }, { - kind: "tools", - batch: { assistantEntryId: "assistant", configuration, turnId: "turn", calls: toolCalls }, - }, - { - kind: "compaction", - reason: "threshold", - structural: structuralDecisions[0], + ...runScope, + at: "run.compaction.retry_wait", + reason: "overflow", resumeAfter: checkpoint, + summaryContext, + nextAttempt: 2, + notBefore: 10, + errorMessage: "retry", }, - { kind: "deferred", deferred: deferredStates[0] }, { - kind: "failure_drain", + ...runScope, + at: "run.failure_drain", error: { code: "provider", message: "failed" }, - provenance: { kind: "response", entryId: "response" }, + provenance: provenances[0]!, }, + { at: "compaction.deciding", control: runningControl, customInstructions: "compact", taskId: "task" }, + { at: "compaction.ready", control: runningControl, summaryContext, nextAttempt: 1 }, + { at: "compaction.effect_pending", control: runningControl, summaryContext, attempt: 1, usageIds: ["usage"] }, { - kind: "failure_drain", - error: { - code: "model_unavailable", - message: "Model is unavailable", - details: { provider: "provider", modelId: "model" }, - }, - provenance: { kind: "configuration" }, - }, - { - kind: "failure_drain", - error: { - code: "configured_tools_unavailable", - message: "Configured tools are unavailable", - details: { tools: ["read"] }, - }, - provenance: { kind: "configuration" }, - }, -]; - -const runState = { - kind: "run", - control: runningControl, - settings: { - compaction: { enabled: true, reserveTokens: 1000, keepRecentTokens: 2000 }, - steeringMode: "all", - followUpMode: "one-at-a-time", - toolExecution: "parallel", - }, - phase: runPhases[0]!, - inbox: { steer: [], followUp: [], writes: [] }, - latestAssistantEntryId: null, -} satisfies RunState; -const compactionState = { - kind: "compaction", - control: runningControl, - customInstructions: "compact", - structural: structuralDecisions[0], -} satisfies CompactionState; -const navigationStates = [ - { - kind: "navigation", + at: "compaction.retry_wait", control: runningControl, - targetId: null, - summarize: false, - phase: { kind: "ready_to_commit" }, + summaryContext, + nextAttempt: 2, + notBefore: 10, + errorMessage: "retry", }, + { at: "navigation.ready_to_commit", control: runningControl, targetId: null, label: "target" }, + { at: "navigation.summary.deciding", control: runningControl, targetId: "target", taskId: "task" }, + { at: "navigation.summary.ready", control: runningControl, targetId: "target", summaryContext, nextAttempt: 1 }, { - kind: "navigation", + at: "navigation.summary.effect_pending", control: runningControl, targetId: "target", - summarize: true, - phase: { kind: "summary", structural: structuralDecisions[0] }, + summaryContext, + attempt: 1, + usageIds: [], }, -] satisfies NavigationState[]; -const operationStates = [runState, compactionState, ...navigationStates] satisfies OperationState[]; + { + at: "navigation.summary.retry_wait", + control: runningControl, + targetId: "target", + summaryContext, + nextAttempt: 2, + notBefore: 10, + errorMessage: "retry", + }, +] satisfies OperationState[]; const operations = [ { operationId: "run", @@ -338,21 +342,34 @@ it("covers the complete durable storage and Part 3 discriminants", () => { >(); expectTypeOf().toEqualTypeOf<"run" | "compaction" | "navigation">(); expectTypeOf().toEqualTypeOf<"running" | "cancel_requested">(); - expectTypeOf().toEqualTypeOf<"ready" | "effect_pending" | "retry_wait">(); expectTypeOf().toEqualTypeOf<"planned" | "effect_pending" | "outcome_ready" | "completed">(); - expectTypeOf().toEqualTypeOf<"suspended" | "effect_pending">(); - expectTypeOf().toEqualTypeOf<"ready" | "effect_pending" | "retry_wait">(); - expectTypeOf().toEqualTypeOf<"deciding" | "generating">(); - expectTypeOf().toEqualTypeOf< - "starting" | "checkpoint" | "assistant" | "tools" | "compaction" | "deferred" | "failure_drain" + expectTypeOf().toEqualTypeOf< + | "run.starting" + | "run.checkpoint" + | "run.assistant.ready" + | "run.assistant.effect_pending" + | "run.assistant.retry_wait" + | "run.tools" + | "run.deferred.suspended" + | "run.deferred.effect_pending" + | "run.compaction.deciding" + | "run.compaction.ready" + | "run.compaction.effect_pending" + | "run.compaction.retry_wait" + | "run.failure_drain" + | "compaction.deciding" + | "compaction.ready" + | "compaction.effect_pending" + | "compaction.retry_wait" + | "navigation.ready_to_commit" + | "navigation.summary.deciding" + | "navigation.summary.ready" + | "navigation.summary.effect_pending" + | "navigation.summary.retry_wait" >(); - expectTypeOf().toEqualTypeOf<"run" | "compaction" | "navigation">(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"response" | "structural" | "configuration">(); expectTypeOf().toEqualTypeOf<"message" | "compaction" | "branch_summary" | "custom">(); void transaction; - void generations; - void deferredStates; - void summaryGenerations; void operationStates; const compileTimeFailures = () => { @@ -479,9 +496,9 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", | "hooks" | "activeDrive" | "command" - | "commandDriveOwned" + | "mutateOperation" + | "advanceOperation" | "readConfig" - | "isDriveActive" | "mismatch" > >().toEqualTypeOf(); @@ -492,9 +509,9 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", | "models" | "activeDrive" | "command" - | "commandDriveOwned" + | "mutateOperation" + | "advanceOperation" | "readConfig" - | "isDriveActive" | "mismatch" > >().toEqualTypeOf();