From fa35550e95e9854a51b6d984cf4db5017dff41f0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 24 Aug 2026 12:37:35 +0200 Subject: [PATCH] feat(agent): simplify durable drive ownership --- packages/agent/docs/harness.md | 260 +++--- .../work-packages/05-direct-durable-drive.md | 821 ++++++------------ packages/agent/src/harness/agent-harness.ts | 93 +- packages/agent/src/harness/result.ts | 55 ++ .../agent/src/harness/runtime2/harness.ts | 3 +- packages/agent/src/harness/runtime2/lane.ts | 59 +- .../agent/src/harness/runtime2/progress.ts | 47 +- packages/agent/src/harness/runtime2/types.ts | 140 ++- .../test/harness/runtime2/progress.test.ts | 228 ++--- .../agent/test/harness/runtime2/test-utils.ts | 38 +- packages/agent/test/harness/types.test.ts | 10 +- 11 files changed, 678 insertions(+), 1076 deletions(-) diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index 6d9f32de6..72883d46f 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -1451,7 +1451,7 @@ type NavigationState = Structural preparation is built from an observed source leaf 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. -One structural attempt may make one or two sequential provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageId}`, synchronously checks `EffectGate.assertOpen()` immediately before invoking that request, 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. +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. ## 3.3 Lane state and the restore projection @@ -1550,7 +1550,7 @@ Structural preparation may be computed outside the lane line against an observed Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), and `InvalidMessage` when acceptance would append zero entries. Acceptance does not consult process-local model/tool registries; unavailable captured implementations become in-band configuration failures at their actual execution boundary. An empty normalized caller request is valid only when the command captures at least one `nextRun` item; no later hook can rescue empty acceptance. -**Acceptance must observe `currentOperationId === null`.** Because the check occurs on the sole lane mutation line, it is validation, not compare-and-swap. Concurrent accepts serialize naturally; the loser observes the committed operation and returns `LaneBusy`. Acceptance never installs `ActiveOperation`. Convenience calls and hosted schedulers use the same accepted state: `prompt`/`skill`/`compact`/`navigateTree` call `accept`, then `drive`; a serving layer may return the admission, schedule a durable wake, and drive later. A crash after acceptance but before drive restores the initial committed phase and starts no work automatically. +**Acceptance must observe `currentOperationId === null`.** Because the check occurs on the sole lane mutation line, it is validation, not compare-and-swap. Concurrent accepts serialize naturally; the loser observes the committed operation and returns `LaneBusy`. Acceptance never installs a `Drive`. Convenience calls and hosted schedulers use the same accepted state: `prompt`/`skill`/`compact`/`navigateTree` call `accept`, then `drive`; a serving layer may return the admission, schedule a durable wake, and drive later. A crash after acceptance but before drive restores the initial committed phase and starts no work automatically. ## 3.7 Assistant generation @@ -1893,10 +1893,16 @@ Public operation status is a small process-relative observation, not a predictio 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. ```ts -interface ActiveOperation { - operationId: string; - completion: Promise; - effectGate: EffectGate; +class Drive { + readonly operationId: string; + readonly completion: Promise; // shared by installer and joiners + readonly gate: Gate; + readonly context: Context; // installer cancellation removed + readonly installerSignal: AbortSignal | undefined; + readonly waitForRetry: boolean; + deferredPermits: number; + finalizedOutcome?: DriveOutcome; + // private GateControl and first-call-wins settle/fail closures } class AbortRequested extends Error { @@ -1908,9 +1914,10 @@ class AbortRequested extends Error { } class OperationEnded extends Error {} +class DriveAbandoned extends Error {} ``` -This is not another state machine. `completion` is the ordinary promise returned by the async operation procedure. `effectGate` owns only abort-versus-start arbitration and the operation's cooperative signal (§4.2). Current 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 are ordinary lane fields used only for snapshots. +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. `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: @@ -1969,7 +1976,7 @@ async function executeDrivePass(operationId: string, } ``` -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 scope 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. +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: @@ -1986,18 +1993,18 @@ Normal execution handles checkpoints, ready/retry states, planned or locally own ### Task installation, joining, and removal -`accept` commits the first durable state and returns without installing `ActiveOperation`. A durably accepted but locally unowned operation is ordinary. `drive({ operationId }, context)` owns one pass. On the lane mutation line it reads current ownership and `pi.lane.lastResult`, then applies exactly one rule: +`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 lane mutation line it reads current ownership and `pi.lane.lastResult`, 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 `ActiveOperation`: join that pass's `completion`; -- the same operation has no task: install one deferred completion promise and fresh gate before releasing the lane line, then start the pass after `Session.mutate` returns. +- 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 lane 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 receive the same `DriveOutcome`; a convenience caller whose policy requires more progress may call `drive` again after that pass returns. 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. +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 after publishing its final local events, it performs a no-write lane job that verifies it still owns the slot and removes its `ActiveOperation`; only then does `completion` resolve/reject. Close/fault removes records under the harness admission barrier. This keeps a lane locally busy until the old continuation and observations are actually done, even when its terminal transaction already made the durable lane idle. External finalization uses the same eventual `finally` removal after signalling the task (§4.9). +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 settles completion immediately but deliberately retains the Drive until the task's eventual `finally`, preserving `LaneBusy` during detached cleanup (§4.9). 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. @@ -2005,129 +2012,88 @@ Convenience methods add policy, not another execution path. `prompt`/`skill`/`co ## 4.2 Effect gate -`Session.mutate` orders every durable race, but admission of a hook/provider/tool/timer operation does not occur inside a session transaction. `EffectGate` closes that one gap. Conceptually it has three states: +`Session.mutate` orders durable races, but ordinary hook/provider/tool/timer admission occurs outside a transaction. Each installed `Drive` owns a split gate: ```ts -type EffectGateState = +interface Gate { + readonly signal: AbortSignal; + /** Synchronously checks admission and invokes the operation with no yield between them. */ + admit(invoke: () => T): T; +} + +interface GateControl { + beginAbort(cancellation: Promise): void; + signalAbort(): void; + close(error: HarnessClosed | HarnessFault | OperationEnded | DriveAbandoned): void; +} +``` + +Procedures receive only `drive.gate`. `Drive` privately retains `GateControl` and exposes owner lifecycle methods `beginAbort`, `signalAbort`, and `closeGate`; there is no procedure-facing `assertOpen` method. + +The gate has three process-local states: + +```ts +type GateState = | { status: "open" } | { status: "aborting"; cancellation: Promise } - | { status: "closed"; error: HarnessClosed | HarnessFault | OperationEnded }; - -interface EffectGate { - /** The operation-owned cooperative signal. */ - readonly signal: AbortSignal; - /** Synchronously throws when ordinary work may no longer start. */ - assertOpen(): void; - /** Synchronously closes ordinary starts and stores the durable-marker promise. */ - beginAbort(cancellation: Promise): void; - /** Pull the controller only after the cancellation commit succeeds. */ - signalAbort(): void; - close(error: HarnessClosed | HarnessFault | OperationEnded): void; -} + | { status: "closed"; error: Error }; ``` -The gate privately owns the corresponding `AbortController`. `requestAbort(operationId, context)` is the cancellation primitive. When the expected operation has a live pass, it creates a deferred abort-mutation promise and calls `beginAbort(promise)` synchronously before performing the lane mutation. If the marker commits, it resolves the promise and calls `signalAbort()`. If the expected operation no longer owns the lane, it resolves the promise, closes the gate with `OperationEnded`, and returns `OperationMismatch`. With no live pass it commits or observes the same durable marker but installs no task; a later `drive` performs reconciliation. A commit fault rejects the promise and closes the gate with `HarnessFault`. `assertOpen()` is a synchronous check: +`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. + +**The admitted-operation boundary is load-bearing.** Preparation finishes first; then the gate check and operation invocation are one synchronous expression: ```ts -assertOpen(): void { - if (state.status === "aborting") - throw new AbortRequested(state.cancellation); - if (state.status === "closed") throw state.error; -} +// WRONG: abort can win while preparation awaits after admission. +const stale = drive.gate.admit(() => prepareRequest()); +await stale; +models.streamSimple(model, aiContext, options); + +// Correct: all preparation finished; check and invocation are adjacent. +await prepareRequest(); +const admittedContext = withAbortSignal(drive.gate.signal, drive.context); +const stream = drive.gate.admit(() => + models.streamSimple(model, aiContext, { + ...options, + signal: admittedContext.abortSignal, + telemetryContext: admittedContext.telemetryContext, + }), +); ``` -`AbortRequested` is internal expected gate control flow. It carries the in-progress abort-mutation promise. That promise resolves when the lane job either commits cancellation or observes that a terminal transaction already won; it rejects on fault/close. The nearest procedure with relevant local work catches it; otherwise the operation-task boundary catches it, waits for that lane result, reloads, and either reconciles durable cancellation or resolves the already-terminal result. `HarnessClosed` and `HarnessFault` follow their ordinary rejection paths and perform no cancellation write. +The admitted boundary is the public Models/tool/hook operation, not an eventual SDK syscall. A Models call synchronously returns a lazy stream; later auth resolution, provider loading, and delegation remain part of that admitted operation and own the same signal. -For a driven operation, the gate applies to starting ordinary hooks, provider requests/fetches, real tools, and convenience-owned retry timers. It does not wrap commits: dependent commits use `Session.mutate`. Both `before_drive` and `before_run` execute only after an operation owns a real drive pass, so they use the same operation gate. Best-effort `cancelDeferred` is cancellation cleanup, not ordinary work; it runs only after durable cancellation and uses a close-only signal. +The complete admission catalog is: -**The admitted-operation boundary is load-bearing.** Preparation must finish first. Then `assertOpen()` and the operation invocation must be adjacent synchronous statements: +**Accepted hook aggregates:** `before_drive`, `before_run`, `before_run_end`, `transform_context`, `before_request`, `before_payload`, `after_response`, `before_tool`, `after_tool`, `before_compaction`, `before_navigation`. One `admit` wraps the complete registered pipeline, not each handler. -```ts -// WRONG: abort may close the gate while this preparation awaits. -effectGate.assertOpen(); -const staleAdmittedContext = withAbortSignal(effectGate.signal, context); -await prepareRequest(); -models.streamSimple(model, aiContext, { - ...options, - signal: staleAdmittedContext.abortSignal, - telemetryContext: staleAdmittedContext.telemetryContext, -}); +**Provider operations:** one assistant `Models.streamSimple`, each individual structural-summary request, and one explicit `Models.fetchDeferred` poll. Best-effort `cancelDeferred` is cancellation cleanup and uses its separate close-only signal. -// Correct: JavaScript cannot interleave another task between these statements. -await prepareRequest(); -effectGate.assertOpen(); -const admittedContext = withAbortSignal(effectGate.signal, context); -admittedContext.abortSignal?.throwIfAborted(); -models.streamSimple(model, aiContext, { - ...options, - signal: admittedContext.abortSignal, - telemetryContext: admittedContext.telemetryContext, -}); -``` +**Other ordinary operations:** one real `tool.execute` and creation of each assistant/structural retry timer. Unknown, invalid, blocked, and synthetic tool outcomes start no tool and use no gate. -The boundary is the public invocation of the operation, not necessarily the eventual SDK/network syscall. `Models.streamSimple()` synchronously returns and starts a lazy stream; its later auth resolution and provider-module loading are part of that admitted request. If abort wins before the call, Models is never invoked. If start wins, abort later pulls the signal and the admitted setup/provider path must stop cooperatively. The same definition applies to a hook aggregate: the complete registered pipeline is one operation admitted by invoking the aggregate runner. +No other code calls `Gate.admit`. It does not wrap commits, public queue/configuration/value/tree mutations, pure classification, transaction construction, synthetic settlement, argument/system/context preparation, an already-admitted promise, cancellation reconciliation, or passive listeners. -To contain the footgun, the following catalog is complete. Only these low-level integrations own an adjacent `assertOpen()`/invocation pair. - -**Accepted-operation hook aggregates:** - -- `before_drive`; -- `before_run`; -- `before_run_end`; -- `transform_context`; -- `before_request`; -- `before_payload`; -- `after_response`; -- `before_tool`; -- `after_tool`; -- `before_compaction`; -- `before_navigation`. - -Each item means one check before invoking the complete registered pipeline, not one check per handler. Once admitted, that aggregate finishes under its hook rules. `before_drive` is fail-closed; isolated failures in the other aggregates are reported and skipped unless their contract says otherwise. - -**Provider operations:** - -- an assistant `Models.streamSimple(...)` operation; -- each sequential provider operation inside a structural summary attempt; -- one `Models.fetchDeferred(...)` operation for an explicit poll. - -Request preparation, implementation resolution, context transformation, and option composition finish before the provider check. Abort-first means `Models` is never called; admission-first means the complete Models request—including later auth resolution, lazy loading, and provider delegation—is already admitted and owns `admittedContext.abortSignal`, which composes the invocation and effect-gate signals. Those later stages are not new effect starts. Best-effort `Models.cancelDeferred(...)` is cancellation cleanup and uses the separate close-only gate/signal, not the operation gate. - -**Other ordinary operations:** - -- one real `tool.execute(...)` invocation; unknown, invalid, blocked, and other immediate tool outcomes do not check the gate because they start no tool; -- timer creation for every assistant or structural `retry_wait` sleep. - -No other code calls `assertOpen()`. In particular, it is not used for: - -- `Session.mutate` or any public queue/configuration/value/tree mutation; -- pure classification, transaction construction, or synthetic settlement; -- `prepareArguments`, `systemPrompt`, `toolContext`, `toProviderMessages`, or entry projectors; -- awaiting a provider/tool promise that was already admitted; -- cancellation reconciliation, except its separate close-only deferred-cancellation cleanup; -- passive event listeners; - -Each listed integration has abort-first/admission-first tests. The assistant block receives the invocation Context for signal-aware preparation; only the admitted request adapter synchronously composes `effectGate.signal` into a child Context immediately before Models invocation. - -`requestAbort` starts by synchronously changing a matching live gate to `aborting` and installing the abort-mutation promise. It then attempts to commit `cancel_requested`; only after that commit succeeds does it pull the controller. Thus the only orders are: +The two possible orders are: ```text -operation admission first -→ assertOpen and operation invocation happen synchronously -→ abort closes gate -→ abort commit becomes durable -→ controller signals the already-started effect +admission first +→ Gate.admit checks and invokes synchronously +→ requestAbort begins durable cancellation +→ cancellation commit lands +→ signalAbort pulls the already-admitted operation signal abort first -→ gate becomes aborting synchronously -→ later assertOpen throws AbortRequested -→ no operation invocation occurs -→ task waits for the abort commit and reconciles +→ beginAbort closes ordinary admission synchronously +→ later Gate.admit throws AbortRequested +→ invoke is never called +→ task waits for the marker and reconciles ``` -This gate is not durable state, a mutex, a scheduler, or a second mutation line. If the process dies before the cancellation commit, its closed gate disappears and no cancellation exists. Recovery trusts only `pi.op.state.control`. +The gate is not durable state, a mutex, scheduler, or mutation line. If the process dies before the cancellation commit, the closed gate disappears and no cancellation exists. Recovery trusts only durable control. -Every procedure boundary emits its existing telemetry span around the ordinary code it executes. +Every catalog item has abort-first/admission-first tests. Preparation must precede `admit`, and the admitted signal must reach asynchronous Models auth/loading/provider work. ## 4.3 The lane mutation line @@ -2197,7 +2163,7 @@ The lane's configured model identity comes from `laneConfig`. A current operatio ## 4.5 Driving and crash recovery -Recovery begins only when an open operation has no `ActiveOperation` and a matching `drive({ operationId }, context)` installs a real pass owner. `AgentHarness.create(options, context)` never drives. `resume(context)` inspects and drives the current operation without exposing its id and grants the same pass one deferred-poll permit. `requestAbort(operationId, context)` with no task commits cancellation but installs nothing; the next drive enters cancellation reconciliation directly. +Recovery begins only when an open operation has no `Drive` and a matching `drive({ operationId }, context)` installs a real pass owner. `AgentHarness.create(options, context)` never drives. `resume(context)` inspects and drives the current operation without exposing its id and grants the same pass one deferred-poll permit. `requestAbort(operationId, context)` with no task commits cancellation but installs nothing; the next drive enters cancellation reconciliation directly. The pass first reloads durable control. If cancellation is requested, it invokes neither `before_drive` nor `before_run` and enters §4.6. Otherwise it gates and invokes `before_drive`; failure rejects this pass without faulting the harness or writing durable progress. Model/tool implementations are resolved only at the actual operation boundary that needs them. An unavailable provider/model or configured request tool becomes a non-retryable configuration failure before request intent; an unavailable requested tool becomes a synthetic error result. Neither condition suspends the operation. Durable phase then decides the work: `starting` runs and settles `before_run` as §3.6 specifies; an unowned pending effect is orphaned by construction and follows the table below; all other phases continue ordinarily. @@ -2229,7 +2195,7 @@ retry_wait → caller schedules a wake and later drives the same operation id retry_wait -→ drive({ waitForRetry: true }) admits and immediately starts the retry timer through EffectGate +→ drive({ waitForRetry: true }) admits and immediately starts the retry timer through `drive.gate` → timer reaches notBefore: Session.mutate verifies the same wait and commits ready → requestAbort: timer wakes after durable cancellation and reconciliation runs → close: local task rejects; no durable write @@ -2246,14 +2212,12 @@ Cancellation is durable control plus process-local prevention/signalling. It is For a live operation, the first matching `requestAbort()` performs this order: ```text -1. synchronously put the matching `ActiveOperation.effectGate` into aborting state and install - the abort-mutation promise; +1. synchronously call `beginAbort` on the matching Drive and install the abort-mutation promise; 2. `Session.mutate` rereads the latest operation and either commits `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 pull the `EffectGate`'s - `AbortController`; after an ownership mismatch, resolve it and close with `OperationEnded`; +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`; 5. return `AbortRequestResult` once cancellation is durable, or `OperationMismatch` when the fence failed; a matching live pass continues reconciliation. ``` @@ -2270,7 +2234,7 @@ With queued steer/follow-up, successful abort removes their ids from the inbox, ### No live task -After reopen or suspension there may be no gate to close or work to signal. `requestAbort(operationId, context)` then writes or observes `cancel_requested` and returns without installing `ActiveOperation`. A scheduler sets a wake and later calls `drive`. The `abort(context)` convenience ensures a same-id drive pass is claimed after the marker and proceeds directly to reconciliation. A crash between the marker and that claim is harmless because the next drive observes durable cancellation first. +After reopen or suspension there may be no gate to close or work to signal. `requestAbort(operationId, context)` then writes or observes `cancel_requested` and returns without installing a `Drive`. A scheduler sets a wake and later calls `drive`. The `abort(context)` convenience ensures a same-id drive pass is claimed after the marker and proceeds directly to reconciliation. A crash between the marker and that claim is harmless because the next drive observes durable cancellation first. ### Reconciliation @@ -2286,7 +2250,7 @@ Cancellation reconciliation is separate from ordinary dispatch. It never starts - accepted deferred writes are still appended in order; - operation-owned pending and drained values are deleted by the aborted terminal transaction. -A scope holding a response or parallel tool promises performs the relevant local settlement before handing off to the shared remainder. This is why those values stay procedure-local rather than moving into a generic runtime object. +A procedure holding a response or parallel tool promises performs the relevant local settlement before handing off to the shared remainder. This is why those values stay procedure-local rather than moving into a generic runtime object. Post-effect hooks obey the same gate: abort before a not-yet-started `after_response`/`after_tool` prevents the hook; assistant/fetch uses the raw response and a live tool uses the raw result. Hook start first lets the complete aggregate finish and uses its transformed value. Already-running hooks are not forcibly interrupted. @@ -2302,7 +2266,7 @@ Close is not abort and writes no cancellation or terminal state: ```text seal harness admission -→ close every live EffectGate +→ close every live Drive gate → pull cooperative signals → reject local operation promises → let commits admitted before the seal drain @@ -2315,17 +2279,17 @@ Reopening reports the open operation. A later matching `drive(options, context)` ## 4.8 Faults -A failed admitted storage commit faults the whole harness. Fault closes effect gates, rejects barriers and pending/future calls with `HarnessFault`, and requires process restart; it is never an expected `Err` result. `faulted:true` appears in snapshots obtained before observation closes. Reopen restores from the last successful transactions. +A failed admitted storage commit faults the whole harness. Fault closes Drive gates, rejects barriers and pending/future calls with `HarnessFault`, and requires process restart; it is never an expected `Err` result. `faulted:true` appears in snapshots obtained before observation closes. Reopen restores from the last successful transactions. 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. It uses the same lane mutation line. This specification adds no public `forceFinalize()` method: authorization, SLA policy, and initiation belong to repository or hosting administration, while the terminal-transaction helper and live-task notification remain package-internal. The next task mutation rereads current values and finds that its operation is no longer current; the mutation raises the internal `OperationEnded` signal rather than returning a normal stale boolean. +An authorized administrative operation may commit the terminal transaction while a task still exists. It uses the same lane mutation line. This specification adds no public `forceFinalize()` method: authorization, SLA policy, and initiation belong to repository or hosting administration, while the terminal-transaction helper and live-task notification remain package-internal. Live admitted work receives `OperationEnded` when the finalizer closes `drive.gate`. Any queued or later planner rereads ownership, returns `lost_ownership`, and commits nothing. -The task then stops, closes its effect gate, discards process-local results without writing, emits end observation, and resolves from the matching `pi.lane.lastResult` written by the terminal transaction. It never 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 durable ownership when their lane job executes, so a write enqueued before finalization is deleted by the terminal cleanup (which removes all operation-owned values/lists) and a write after it rejects without 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`. +With a live in-process Drive, the finalizer itself publishes the end observation, records `drive.finalizedOutcome`, closes the gate, and settles shared completion immediately; 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 durable ownership when their lane job executes, 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 rereads ownership on the lane line before writing. An in-process finalizer publishes the terminal events, records the matching result on the `ActiveOperation`, closes its gate with `OperationEnded` immediately after the terminal commit, and pulls the controller so cooperative provider/tool work stops. The task then resolves without publishing a duplicate terminal event once its current awaited work returns; its outer `finally` removes the record. A new operation therefore waits/gets `LaneBusy` during that cleanup even though durable state is already idle. A replacement-process finalizer first acquires ownership after close/crash, so no live task exists to notify. +At most one terminal transaction commits because every candidate rereads ownership on the lane line before writing. An in-process finalizer publishes terminal events, records the matching result in `drive.finalizedOutcome`, closes its gate with `OperationEnded`, and settles the shared completion immediately so non-cooperative provider/tool work cannot delay callers. It deliberately retains the Drive; acceptance returns `LaneBusy` until the detached task reaches its exact-object `finally`. 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 @@ -3121,7 +3085,7 @@ Uniform semantics: - Durability is hook-specific, as classified below. Hook completion is never itself a durable fact. - Events expose post-hook values. Passive listeners cannot transform them. -One accepted-operation hook invocation calls `EffectGate.assertOpen()`, then immediately invokes the complete registered pipeline; individual handlers are not separate gate checks. Once admitted, the aggregate follows its isolation rule. Aggregation is deterministic: +One accepted-operation hook invocation calls `drive.gate.admit(() => runPipeline(...))`; individual handlers are not separate gate checks. Once admitted, the aggregate follows its isolation rule. Aggregation is deterministic: - `before_run` appends messages; each later handler sees the prompt plus prior injections. The consuming `starting → checkpoint` transaction applies all injected messages once. - `transform_context`, request/payload/response, and `after_tool` transformations run in registration order, each seeing the prior transformed value; option/result patches merge field by field. @@ -3172,7 +3136,7 @@ The harness owns purpose-built execution blocks under `packages/agent/src/harnes ### Assistant streaming -`assistant.ts` owns one already-approved provider request. Before the request intent commits, the assistant procedure verifies that the captured durable `{ provider, modelId }` resolves in the harness's `Models` registry and runs `before_request`. After that commit, the supplied request adapter resolves the same pair, calls `EffectGate.assertOpen()`, derives the admitted Context synchronously, and immediately invokes `Models` under its composed abort signal and telemetry parent. The block itself receives only executable values: +`assistant.ts` owns one already-approved provider request. Before the request intent commits, the assistant procedure verifies that the captured durable `{ provider, modelId }` resolves in the harness's `Models` registry and runs `before_request`. After that commit, the supplied request adapter resolves the same pair, derives the admitted Context, and invokes `Models` through `drive.gate.admit(...)` under its composed abort signal and telemetry parent. The block itself receives only executable values: ```ts interface AssistantResponseMetadata { @@ -3207,8 +3171,8 @@ interface HarnessAssistantStreamConfig { afterResponse: (message: SettledAssistantMessage, metadata: AssistantResponseMetadata, context: Context) => Promise; - /** Models-backed adapter. It resolves the captured identity, synchronously - checks EffectGate, then immediately invokes Models (§4.2). */ + /** Models-backed adapter. It resolves the captured identity and invokes + Models through the Drive's Gate (§4.2). */ request(aiContext: AiContext, options: SimpleStreamOptions, context: Context): AssistantMessageEventStream | Promise; @@ -3245,18 +3209,16 @@ The request function, not this block, owns registry dispatch, auth, and operatio ```ts (aiContext, options, context) => { const model = resolveCapturedModel(); - effectGate.assertOpen(); - const admittedContext = withAbortSignal(effectGate.signal, context); - admittedContext.abortSignal?.throwIfAborted(); - return models.streamSimple(model, aiContext, { + const admittedContext = withAbortSignal(drive.gate.signal, context); + return drive.gate.admit(() => models.streamSimple(model, aiContext, { ...options, signal: admittedContext.abortSignal, telemetryContext: admittedContext.telemetryContext, - }); + })); } ``` -There is no yield between `assertOpen()`, synchronous admitted-Context derivation, and `Models.streamSimple()`. Its asynchronous auth/lazy/provider work is part of the admitted request and owns `admittedContext.abortSignal` (§4.2). A captured identity that disappears after intent becomes an in-band provider error under the reserved response/usage ids. An identity unavailable before intent becomes a non-retryable configuration failure with no fabricated response or usage (§3.7, §4.5). Existing summary helpers keep their separate `Models`-based generation logic, but gate their `Models` operation invocation the same way. +There is no yield between `Gate.admit` checking admission and invoking `Models.streamSimple()`. Its asynchronous auth/lazy/provider work is part of the admitted request and owns `admittedContext.abortSignal` (§4.2). A captured identity that disappears after intent becomes an in-band provider error under the reserved response/usage ids. An identity unavailable before intent becomes a non-retryable configuration failure with no fabricated response or usage (§3.7, §4.5). Existing summary helpers keep their separate `Models`-based generation logic, but gate their `Models` operation invocation the same way. ### Tool phases @@ -3313,7 +3275,7 @@ function prepareToolCall(call: AgentToolCall, tools: AgentTool[]): function applyBeforeToolDecision(prepared: PreparedToolCall, decision: BeforeToolDecision | undefined): ClearedToolCall | ImmediateToolOutcome; -function executeToolCall(call: ClearedToolCall, effectGate: EffectGate, +function executeToolCall(call: ClearedToolCall, gate: Gate, onUpdate: AgentHarnessToolUpdateCallback, context: Context): Promise; function finalizeToolCall(call: ClearedToolCall, executed: ExecutedToolCall, @@ -3339,7 +3301,7 @@ planned call → materialize source-ready outcomes as entries + usage ``` -Unknown tools, `prepareArguments` failures, invalid initial/replacement arguments, and blocked calls produce `ImmediateToolOutcome` and skip intent/execution before staging a synthetic `outcome_ready` result. `AgentTool.prepareArguments` remains deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. At `tool.execute` operation admission, `executeToolCall` calls `EffectGate.assertOpen()`, synchronously derives `withAbortSignal(effectGate.signal, context)`, checks that admitted signal, and immediately invokes the neutral `AgentTool` adapter with it. The bound harness adapter passes that admitted Context to `AgentHarnessTool.execute` in its trailing position. The block converts expected tool throws to an error result, stops accepting updates when the tool promise settles, and emits raw tool-effect telemetry. Every update emits live observation; `checkpoint:true` additionally enqueues one invocation-fenced `pi.pending.tool_output` replacement and replaces only the latest checkpoint-write promise reference (§3.8). The procedure retains only the latest event-delivery promise because the event bus serializes delivery, and only the latest checkpoint-write promise because the lane line is FIFO; it closes checkpoint admission at tool-promise settlement and awaits both before `after_tool`. `finalizeToolCall` applies the documented field-by-field patch semantics before `tool_end` and outcome staging. +Unknown tools, `prepareArguments` failures, invalid initial/replacement arguments, and blocked calls produce `ImmediateToolOutcome` and skip intent/execution before staging a synthetic `outcome_ready` result. `AgentTool.prepareArguments` remains deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. At `tool.execute` operation admission, `executeToolCall` derives `withAbortSignal(gate.signal, context)` and invokes the neutral `AgentTool` adapter through `gate.admit(...)`. The bound harness adapter passes that admitted Context to `AgentHarnessTool.execute` in its trailing position. The block converts expected tool throws to an error result, stops accepting updates when the tool promise settles, and emits raw tool-effect telemetry. Every update emits live observation; `checkpoint:true` additionally enqueues one invocation-fenced `pi.pending.tool_output` replacement and replaces only the latest checkpoint-write promise reference (§3.8). The procedure retains only the latest event-delivery promise because the event bus serializes delivery, and only the latest checkpoint-write promise because the lane line is FIFO; it closes checkpoint admission at tool-promise settlement and awaits both before `after_tool`. `finalizeToolCall` applies the documented field-by-field patch semantics before `tool_end` and outcome staging. Before starting any call in a live batch, the tool-batch procedure resolves `toolContext` once and binds the complete captured active-tool set into ordinary `AgentTool` adapters retained in a procedure-local snapshot. Each adapter also binds `AgentHarnessToolInvocation { invocationId: resultEntryId, operationId, turnId, getMemo, setMemo }`, the harness-specific update callback, and the current invocation Context. Available captured tools are bound into the snapshot. A requested call whose implementation is absent becomes an ordinary synthetic unknown-tool result with no `details`; it does not suspend the batch. Provider calls to names outside the captured active names follow the same unknown-tool path. Every call in the batch observes the same application context and its own stable invocation identity. Safe replay creates a new code/context snapshot but passes the same invocation id and memos after deleting the stale progress checkpoint. `AgentTool.replay` defaults to `"never"` when omitted. @@ -3504,7 +3466,7 @@ These rows preserve requirements, not frozen package boundaries. Create their ha |---|---|---|---| | S3 | **Search** | The standalone `SessionSearchService` plus repo catch-up utilities (§2.8): core entry search as `SessionSearch`, session-level ranked results, optional `searchEntries?: SessionSearch`, `remove()`, the `SessionSearchSyncTarget` cursor/index-batch contract, sync enumeration and catch-up outside the service contract, debounced notify as a utility, `(sessionId, storeGeneration)` cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository through the sync utility. | Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify-utility/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline. | | S4 | **Dev TUI and Client** | A minimal `AgentClient` over one lane — `LaneSnapshot` plus `watch()` events, `prompt`/`steer`/`followUp`/`abort`/`resume`/`cancelQueued`, `pi.lane.lastResult` read — and a throwaway alt-screen TUI on `packages/tui`: transcript from snapshot and events, input box, status/queue display, abort key. Built first against a scripted fake client on the shared types; binds to the real harness as runtime packages land. Not final. | Compiles; fake-client smoke test. No durability obligations. | -| R2 | **Minimal no-tool drive** | Starting from WP02's durable `starting` operation, add the first real lane-owned `ActiveOperation` pass with expected-id fencing, same-id joining, latest-result lookup, an `EffectGate` and direct durable transitions; run driver-owned `before_drive`/`before_run`, make `prompt`/`skill`/`promptFromTemplate` compose `accept`+`drive`, and execute one assistant generation through captured context/options, payload/response hooks, intent/effect/settlement, synchronous per-event frame appends with latest-promise fencing, atomic frame-list deletion, usage, terminal cleanup plus `pi.lane.lastResult`, results, events, and telemetry. Unavailable captured model or configured active-tool definitions fail in-band before intent with configuration provenance; no response/usage is fabricated. | Accepted `starting` close/reopen/drive; prompt convenience and explicit accept+drive produce byte-identical writes/events; one same-id pass installed/joined and stale ids isolated; deterministic transition coverage; successful run with final assistant fields; one frame append per convertible event with no per-frame storage await and only the latest promise retained; settlement awaits the latest frame write and deletes the list atomically; frame-append failure faults before `after_response`; caller rejection and adapter-reported failures; exact ordering; supplied operation id propagates through `run_end` and `pi.lane.lastResult`; terminal cleanup; close at every boundary; invalid references rejected at consumption. | +| R2 | **Minimal no-tool drive** | Starting from WP02's durable `starting` operation, add the first real lane-owned `Drive` pass with expected-id fencing, same-id joining, latest-result lookup, a split `Gate`/`GateControl`, and direct durable transitions; run driver-owned `before_drive`/`before_run`, make `prompt`/`skill`/`promptFromTemplate` compose `accept`+`drive`, and execute one assistant generation through captured context/options, payload/response hooks, intent/effect/settlement, synchronous per-event frame appends with latest-promise fencing, atomic frame-list deletion, usage, terminal cleanup plus `pi.lane.lastResult`, results, events, and telemetry. Unavailable captured model or configured active-tool definitions fail in-band before intent with configuration provenance; no response/usage is fabricated. | Accepted `starting` close/reopen/drive; prompt convenience and explicit accept+drive produce byte-identical writes/events; one same-id pass installed/joined and stale ids isolated; deterministic transition coverage; successful run with final assistant fields; one frame append per convertible event with no per-frame storage await and only the latest promise retained; settlement awaits the latest frame write and deletes the list atomically; frame-append failure faults before `after_response`; caller rejection and adapter-reported failures; exact ordering; supplied operation id propagates through `run_end` and `pi.lane.lastResult`; terminal cleanup; close at every boundary; invalid references rejected at consumption. | | 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. | @@ -3542,16 +3504,16 @@ Operations: 12. `laneState(lane)` confers lane ownership, and `operationState(operationId)` confers operation-state ownership. An open lane names operation O, `operationMeta(O)` holds that lane's compatible `OperationMeta`, and `operationState(O)` holds an `OperationState` compatible with O's intent kind; state values carry no duplicate owner metadata. 13. Operation-owned values and lists may exist only while their operation is open: the terminal transaction deletes them atomically with clearing `currentOperationId` (§3.13). Lane-owned `pendingNextRun` values are never deleted by it. -14. Acceptance must observe `currentOperationId === null`, commits no `ActiveOperation`, and returns before any hook/provider/tool/timer work begins. Run acceptance commits payload-free `starting`; only its consuming command may apply `before_run` output and replace it with `checkpoint`. A supplied operation id obeys §1.2 and is the exact id written to `pi.op.meta`, events, and `pi.lane.lastResult`. +14. Acceptance must observe `currentOperationId === null`, commits no `Drive`, and returns before any hook/provider/tool/timer work begins. Run acceptance commits payload-free `starting`; only its consuming command may apply `before_run` output and replace it with `checkpoint`. A supplied operation id obeys §1.2 and is the exact id written to `pi.op.meta`, events, and `pi.lane.lastResult`. 15. A reserved id may exist only with the content its intent named. Queued-content ids begin in `pi.pending.entry`; settlement-family ids begin as strings in `pi.op.state`. A tool-result id may then move through `string only → outcome-ready pi.pending.entry → immutable entry`; no two representations coexist at a commit boundary (§2.2). An effect-pending response id may additionally key its auxiliary frame list (§3.7); frames are observation, not a content representation, and die with settlement. 16. Only terminal transitions construct a `LaneLastResult`. A terminal outcome is observable once through the live promise and thereafter through `pi.lane.lastResult` until the next terminal transaction on that lane; recovery never reads it. 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). Detailed presentation references are validated by `watch(context)` under the lane line; drive 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 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. A task mutation that finds its operation values absent raises internal `OperationEnded`; the task stops without writing and resolves from `pi.lane.lastResult` (§4.9). -22. At most one `ActiveOperation` drive pass exists per lane. Acceptance and taskless `requestAbort` never install one. A matching `drive` installs it before releasing the lane mutation line; another matching drive joins that pass, and a stale id starts nothing. Every pass's outer `finally` removes it in a no-write lane job only after final local observation; external finalization retains and signals it until that path runs. 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`. -23. The §4.2 `assertOpen()` catalog is complete. Every listed hook/provider/tool/timer integration checks immediately before invocation with no yield between them; no unlisted code checks the operation gate. Preparation stays before the check, and admitted asynchronous provider setup/delegation are continuations of the same operation owning `EffectGate.signal`. +21. At most one terminal transaction ever commits per operation. In-process finalization closes the live Drive gate with `OperationEnded`, records `drive.finalizedOutcome`, and settles immediately; 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 lane mutation line; another matching drive joins that pass, and a stale id starts nothing. Every late transition checks exact Drive identity. Normal `finally` removes only itself; abandonment and close/fault remove immediately; external finalization settles immediately but retains ownership until `finally`. 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`. +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. 26. Convenience operations and their explicit primitive compositions produce the same durable writes, events, results, and recovery behavior. Their only difference is process-local waiting/scheduling policy. @@ -3581,9 +3543,9 @@ 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 rejects without recreating state | +| 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 → durable ownership check rejects the mutation | +| external finalization vs memo/checkpoint mutation | mutation first → terminal cleanup deletes it; finalization first → durable ownership 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` | @@ -3599,7 +3561,7 @@ Each durable mutation race has exactly two durable histories. Process-local owne | 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 lane-line inspection observes publication | -| concurrent invocation contexts | each call/event/session write retains its own telemetry parent and abort signal; canceling one does not affect the other or write durable cancellation | +| 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 | | `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | ## 9.3 Test tiers @@ -3620,7 +3582,7 @@ One corruption assertion constructs an `aborted` response with running control d - **Attachment and watch.** Construct every durable phase directly and assert minimal open inventory, configured/captured identity inspection without resolution, projection corruption faulting create, presentation corruption faulting watch, exact required/optional ad-hoc reads, no attachment effects, lane-line inspection, complete snapshots, live-over-durable partial precedence, no historical lifecycle replay, recipient binding at `emitBatch`, and both registration/publication orders without gaps or duplicates. - **Drive equivalence.** Convenience calls and explicit `accept`/`drive`/`requestAbort` compositions produce byte-identical durable state and equivalent events/results. - **Deterministic transition control.** Test-only storage gating parks commits without production annotations; controlled hooks, providers, tools, and timers expose effect windows. Each runtime slice tests every durable edge and both orders of each owned race. -- **Effect-start gate.** Cover every item in §4.2's complete catalog and assert that no other path calls `assertOpen()`. At each listed integration, force both orders of abort versus operation admission: the check and invocation are adjacent, abort-first invokes nothing, and admission-first gives the complete operation `EffectGate.signal`. Provider tests assert that request preparation precedes the check and that the same signal reaches Models auth/lazy/provider work. Hook tests treat each aggregate pipeline as one admitted unit. A cancelled drive must enter reconciliation without invoking `before_drive` or `before_run`. +- **Effect-start gate.** Cover every item in §4.2's complete catalog and assert that no other path calls `Gate.admit()`. At each listed integration, force both orders of abort versus operation admission: abort-first invokes nothing, and admission-first gives the complete operation `drive.gate.signal`. Provider tests assert that request preparation precedes the check and that the same signal reaches Models auth/lazy/provider work. Hook tests treat each aggregate pipeline as one admitted unit. A cancelled drive must enter reconciliation without invoking `before_drive` or `before_run`. - **Invocation context.** Public operations receive trailing Context; hooks/listeners/callbacks and Session reads/writes preserve it. Cross concurrent calls on one shared receiver and assert independent telemetry/cancellation lineage. Buffered delivery retains the object-identical emitting Context. Context is never written durably. An RPC cancel/disconnect aborts only its reconstructed request signal, and invocation cancellation never writes `cancel_requested`. - **Signal ownership.** No public surface accepts a standalone operation signal; invocation cancellation arrives through `Context.abortSignal`, while operation-owned effect signals remain harness-controlled. A `before_request` patch carrying a signal has it stripped. Assert by type and by test. - **Ledger completeness.** Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. `getStats()` equals the ledger sum after every commit. A fork starts at zero. @@ -3651,7 +3613,7 @@ One corruption assertion constructs an `aborted` response with running control d | **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. | -| **Active operation** | The one process-local async task for the lane's current drive pass, with its completion promise and `EffectGate`. | +| **Drive** | The one installed process-local lane pass: exact ownership identity, shared completion, Gate, invocation 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). | @@ -3665,7 +3627,7 @@ 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 from outside the live task; the task detects absent operation values, stops without writing, and resolves from `pi.lane.lastResult` (§4.9). | +| **External finalization** | A terminal transaction committed outside the live task. An in-process finalizer publishes once and settles the live Drive immediately from `finalizedOutcome`; 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/work-packages/05-direct-durable-drive.md b/packages/agent/docs/work-packages/05-direct-durable-drive.md index 3749b4fc1..e3b823137 100644 --- a/packages/agent/docs/work-packages/05-direct-durable-drive.md +++ b/packages/agent/docs/work-packages/05-direct-durable-drive.md @@ -22,7 +22,7 @@ Read completely, in this order, before editing anything. 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/runtime2/lane.ts` — `Lane`, `Lane.command`, `LaneCommand`, `accept`. -6. `packages/agent/src/harness/runtime2/types.ts` — `Config`, `LaneState`, `AcceptanceConfig`, `SliceNotImplemented`. +6. `packages/agent/src/harness/runtime2/types.ts` — `Config`, `LaneState`, `LaneCommand`, `Drive`, `StepResult`, `SliceNotImplemented`. 7. `packages/agent/src/harness/runtime2/harness.ts` — `Harness`, config store, `fault`, `close`. 8. `packages/agent/src/harness/runtime2/restore.ts` — projection restore. 9. `packages/agent/src/harness/execution/effect-gate.ts`, `execution/assistant.ts`, `execution/tools.ts`. @@ -36,24 +36,23 @@ Read completely, in this order, before editing anything. ## 1. Module ownership and import direction ``` -agent-harness.ts public types imports no runtime module +agent-harness.ts public types + constructor -> runtime2/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 -runtime2/types.ts Config / LaneState / ActiveDrive / DriveScope / PassPolicy / StepResult - / LaneDriveCapabilities imports NO runtime2 module (M1) -runtime2/progress.ts progress channels -> runtime2/types, session/values -runtime2/drive/*.ts procedures -> runtime2/types, progress, execution/*, session/values -runtime2/drive.ts the switch -> runtime2/drive/*, runtime2/types (M7 only) -runtime2/lane.ts Lane owner + public surfaces -> runtime2/drive.ts (value import) +runtime2/types.ts Config / LaneState / LaneCommand / Drive / StepResult +runtime2/progress.ts progress channels -> runtime2/types, type-only runtime2/lane +runtime2/drive/*.ts procedures -> runtime2/types, progress, type-only runtime2/lane +runtime2/drive.ts the switch -> runtime2/drive/*, type-only runtime2/lane (M7 only) +runtime2/lane.ts Lane owner + public surfaces -> runtime2/drive.ts (value import, M8) runtime2/harness.ts Harness -> runtime2/lane.ts ``` Rules: -- `drive.ts` and `drive/**` never import `lane.ts` at all — not even `import type`. They reach the owner through `LaneDriveCapabilities` (§2.11), declared in `runtime2/types.ts`. `Lane` implements it, so the dependency points one way and there is no cycle to break. -- No `Deps` object. Procedures receive `DriveScope` (§2.4), which carries that capability interface plus per-pass values — including `owner`, whose `gate` is the sole admission path. -- Admission is always spelled `scope.owner.gate.admit(…)`. There is no second alias for the gate. +- `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`. @@ -65,176 +64,119 @@ Rules: **Approved** = the target structure; deviation needs a stated reason. **Illustrative** = names/fields inside a body that may change while the structure holds. -### 2.1 ActiveDrive + deferred completion + exact-object cleanup — approved +### 2.1 Drive ownership, completion, and exact-object cleanup — approved + +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. ```ts // packages/agent/src/harness/runtime2/types.ts -import type { DriveOutcome } from "../agent-harness.ts"; -import type { Gate, GateControl } from "../execution/effect-gate.ts"; -import { createGate } from "../execution/effect-gate.ts"; - -export interface ActiveDrive { +export class Drive { readonly operationId: string; readonly completion: Promise; readonly gate: Gate; - readonly control: GateControl; - /** - * Set by an in-process finalizer that committed this operation's terminal transaction - * and already published its end event (M7). The task returns this instead of - * deriving and emitting a second one. Mutable by design: the finalizer writes it. - */ + 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; -} -export interface ActiveDriveHandle { - readonly active: ActiveDrive; - settle(outcome: DriveOutcome): void; - fail(error: unknown): void; -} + private readonly control: GateControl; + private settled = false; + // private resolve/reject closures omitted -// ActiveDrive additionally carries `supervisor` (installed by startDrive) so close, fault, -// external finalization, and invocation abandonment can decide the public completion and the -// owner record INDEPENDENTLY. See "Detaching a non-cooperative effect" below. - -export function createActiveDrive(operationId: string): ActiveDriveHandle { - let settle!: (outcome: DriveOutcome) => void; - let fail!: (error: unknown) => void; - const completion = new Promise((resolve, reject) => { - settle = resolve; - fail = reject; - }); - // Joiners may attach later; keep the rejection observed without swallowing it. - void completion.catch(() => {}); - const { gate, control } = createGate(); - return { active: { operationId, completion, gate, control }, settle, fail }; -} -``` - -```ts -// packages/agent/src/harness/runtime2/lane.ts — exact-object cleanup (approved) -private async removeOwner(active: ActiveDrive, context: Context): Promise { - if (this.closedError === undefined) { - try { - await this.command(() => { - if (this.activeDrive === active) this.activeDrive = undefined; - return { kind: "return", result: undefined }; - }, context); - return; - } catch { - // close/fault raced the cleanup job; fall through to process-local removal - } + 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 } - if (this.activeDrive === active) this.activeDrive = undefined; + + settle(outcome: DriveOutcome): void { /* first call wins */ } + fail(error: unknown): void { /* first call wins */ } + beginAbort(cancellation: Promise): void { this.control.beginAbort(cancellation); } + signalAbort(): void { this.control.signalAbort(); } + closeGate(error: Error): void { this.control.close(error); } } ``` -Ordering: `runDrive` settles → final events already delivered (`Lane.command` awaits delivery) → `removeOwner` → `settle()/fail()`. A joiner therefore never observes completion before delivery and cleanup. - -#### Detaching a non-cooperative effect — approved - -Closing the gate is **not** sufficient. `control.close()` pulls an `AbortController`, and a tool or provider adapter that ignores its signal keeps running. If the public completion is simply `await runDrive(…)`, close, fault, external finalization, and invocation abandonment all block behind that effect — the exact hang §4.7 forbids. - -The fix has two halves, and **they are separate decisions**. Settling the public promise and releasing the owner record answer different questions — "what does this caller get?" and "may a new operation start?" — and the three stop paths need different combinations of them. Collapsing them into one `stop` was the defect: it forced finalization to choose between hanging the caller and dropping the `LaneBusy` window. +Procedures receive the concrete lane and drive: ```ts -// packages/agent/src/harness/runtime2/lane.ts — approved +async function runGeneration( + lane: Lane, drive: Drive, run: DurableRun, generation: Generation, +): Promise { /* straight-line procedure */ } +``` -type CompletionResult = - | { kind: "ok"; outcome: DriveOutcome } - | { kind: "error"; error: unknown }; +`drive/**` imports `Lane` with `import type`, so no runtime cycle exists. `Lane` is package-internal and is not exported from the package root. -export interface DriveSupervisor { - /** - * Decide the public completion now, without waiting for an in-flight effect. - * Idempotent: the first call wins, later calls are no-ops. - */ - settleCompletion(result: CompletionResult): void; - /** - * Keep this ActiveDrive installed in lane.activeDrive, so acceptance keeps returning - * LaneBusy. The default state; calling it records the intent explicitly and never - * reverses a removeOwner() that already ran. Idempotent. - */ - retainOwner(): void; - /** - * Detach this ActiveDrive now, by exact object identity, independently of whether the - * pass ever returns. Synchronous and process-local: it must not queue behind a lane job, - * because the point is to release the lane while an effect is still stuck. Idempotent. - */ - removeOwner(): void; +Exact ownership and cleanup stay on `Lane`: + +```ts +// packages/agent/src/harness/runtime2/lane.ts +/** Package-internal so deterministic tests can install and replace exact owners. */ +activeDrive: Drive | undefined; + +isDriveActive(drive: Drive): boolean { + return this.activeDrive === drive; } -private startDrive( - handle: ActiveDriveHandle, options: DriveOptions, context: Context, -): DriveSupervisor { - const active = handle.active; - const scope = this.createDriveScope(active, options, context); - let settled = false; - let removed = false; +private removeDrive(drive: Drive): void { + if (this.activeDrive === drive) this.activeDrive = undefined; +} +``` - const supervisor: DriveSupervisor = { - settleCompletion: (result) => { - if (settled) return; - settled = true; - if (result.kind === "ok") handle.settle(result.outcome); - else handle.fail(result.error); - }, - retainOwner: () => { /* default: the owner stays until removeOwner() */ }, - removeOwner: () => { - if (removed) return; - removed = true; - // Exact object. A replacement owner installed after this point has a different - // identity and is never cleared by the old task (the ABA case below). - if (this.activeDrive === active) this.activeDrive = undefined; - }, - }; - active.supervisor = supervisor; +Every late transition and progress write fences both durable identity and exact process identity: + +```ts +const read = await readDurableRun(reader, lane.name, drive.operationId, drive.context); +if (read.kind === "lost") return { kind: "return", result: { kind: "lost_ownership" } }; +if (!lane.isDriveActive(drive)) return { kind: "return", result: { kind: "lost_ownership" } }; +``` + +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. + +`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(scope); + return await runDrive(this, drive); } finally { - supervisor.removeOwner(); // normal path; idempotent with an earlier call + drive.installerSignal?.removeEventListener("abort", abandon); + this.removeDrive(drive); } })(); - - pass.then( - (outcome) => supervisor.settleCompletion({ kind: "ok", outcome }), - (error) => supervisor.settleCompletion({ kind: "error", error }), - ); - // The detached pass keeps an observer either way, so a rejection arriving after the public - // promise already settled is observed, never unhandled. Nothing awaits this promise. + pass.then((outcome) => drive.settle(outcome), (error) => drive.fail(error)); void pass.catch(() => {}); - - return supervisor; } ``` -`ActiveDrive` gains one field: `supervisor?: DriveSupervisor`. +Completion and owner removal are independent actions, but they need no supervisor object: -Removal is synchronous rather than a lane job. The ordering guarantee that matters — a joiner never observes completion before the final events were delivered — comes from `Lane.command` awaiting delivery **inside** `runDrive` before it returns, not from removal being queued. Queuing removal would make it unreachable in exactly the case it is needed. +| Path | Completion | Owner | +|---|---|---| +| normal return/rejection | pass calls `drive.settle` / `drive.fail` | pass `finally` removes exact drive first | +| external finalization | terminal continuation records `finalizedOutcome`, closes gate, and calls `drive.settle(outcome)` immediately | deliberately **does not** remove; the pass `finally` removes it after detached work returns, preserving `LaneBusy` | +| 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 | -The three stop paths, each choosing both axes independently: +Omitting removal is the retention operation; there is no no-op `retainOwner()` method. -| Path | Public completion | Owner record | Why | -|---|---|---|---| -| external finalization | `settleCompletion({ kind: "ok", outcome })` **immediately**, with the finalizer's settled outcome, even if the effect is non-cooperative | `retainOwner()` — stays until the detached task's own exact-object cleanup | harness.md §4.9: the caller must not wait on a stuck effect, **and** a new operation must get `LaneBusy` during cleanup | -| invocation abandonment | `settleCompletion({ kind: "error", error: DriveAbandoned })` — only this invocation's public drive | `removeOwner()` **immediately**, independent of the pass | durable state stays at its last commit (`effect_pending`); the next drive must be able to claim it and run orphan recovery even if the old effect never returns | -| close / fault | `settleCompletion({ kind: "error", error: HarnessClosed \| HarnessFault })` immediately | either — admission is already sealed, so retention changes nothing observable; `removeOwner()` is the simpler choice | close must not block on a non-cooperative effect (§4.7) | +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. -In all three the gate is closed and its controller pulled first, and the detached pass keeps its observer. - -**ABA fencing.** Immediate removal on abandonment creates a case durable identity alone cannot catch: the old effect returns, a *replacement* owner has since claimed the **same** `operationId`, so `readDurableOperation` returns `ok` and the fence looks valid. Only object identity distinguishes them. Therefore every late transition and every progress write must fence on **both**: - -```ts -const read = await readDurableRun(reader, scope.lane.name, scope.operationId, scope.context); -if (read.kind === "lost") return { kind: "return", result: { kind: "lost_ownership" } }; -// Durable identity is NOT enough: a replacement owner may hold the same operationId. -if (!scope.lane.ownsDrive(scope)) return { kind: "return", result: { kind: "lost_ownership" } }; -``` - -`ownsDrive` is `this.activeDrive === scope.owner`, evaluated inside the planner on the lane line, in the same synchronous stretch as the commit decision. A detached task therefore cannot write through a replacement owner, and cannot clear one either. It publishes no further events, and finalization's end event was already published by the finalizer, so there is no duplicate. - -`startDrive` runs `removeOwner` in a `finally`, so identity cleanup happens on **every** exit — the settled and waiting outcomes, and each rejecting arm of `runDrive`'s catch boundary (§2.4): `DriveAbandoned`, `HarnessClosed`, `HarnessFault`, `OperationMismatch`. External finalization is the one case that deliberately delays it: the finalizer closes the gate but leaves the record in place, and this same `finally` removes it once the task's last local observation is done, which is what makes `accept` return `LaneBusy` during that window (harness.md §4.9). +An in-process finalizer records `drive.finalizedOutcome` before closing the gate. `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 @@ -309,7 +251,7 @@ 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 | `abandonOwner(active, new DriveAbandoned())` (M7): closes the gate, settles **only** this invocation's public drive, 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 | +| 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()` | @@ -321,12 +263,13 @@ The retained pass context is `withoutAbortSignal(installerContext)`; admitted ef ```ts // packages/agent/src/harness/runtime2/lane.ts type DriveClaim = - | { kind: "join"; active: ActiveDrive } - | { kind: "install"; handle: ActiveDriveHandle } + | { 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 })); } @@ -338,7 +281,7 @@ async drive(options: DriveOptions, context: Context): Promise { // delivering final events and removing itself. No durable read needed. const owner = this.activeDrive; if (owner !== undefined && owner.operationId === options.operationId) { - return { kind: "return", result: { kind: "join", active: owner } }; + return { kind: "return", result: { kind: "join", drive: owner } }; } // A configured lane always has laneState (Session.createLane and seedMain write @@ -358,9 +301,9 @@ async drive(options: DriveOptions, context: Context): Promise { // 3. Durable operation matches and has no owner: install. if (currentId === options.operationId) { - const handle = createActiveDrive(options.operationId); - this.activeDrive = handle.active; - return { kind: "return", result: { kind: "install", handle } }; + const drive = new Drive(options, context); + this.activeDrive = drive; + return { kind: "return", result: { kind: "install", drive } }; } // 4. Idle and the latest result is ours: hydrate. @@ -377,22 +320,36 @@ async drive(options: DriveOptions, context: Context): Promise { case "settled": return Result.ok(claim.outcome); case "join": - return awaitCompletion(claim.active); + // A joiner's invocation signal ends only its observation. + return awaitCompletion(observeCompletion(claim.drive.completion, context)); case "install": - this.startDrive(claim.handle, options, context); // AFTER the line released - return awaitCompletion(claim.handle.active); + this.startDrive(claim.drive); // AFTER the line released + // Installer cancellation is translated by startDrive into shared DriveAbandoned. + return awaitCompletion(claim.drive.completion); } } -/** - * The one adapter from owner completion to DriveResult, used by BOTH join and install so - * the two paths cannot drift. `Result.ok(await completion)` was wrong: the completion can - * reject with an expected error that belongs in the Result channel, and with unexpected - * errors that must stay rejections (harness.md §5.1). - */ -async function awaitCompletion(active: ActiveDrive): Promise { +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 active.completion); + return Result.ok(await completion); } catch (error) { // Expected, and declared in DriveResult's error union. if (error instanceof OperationMismatch) return Result.err(error); @@ -414,208 +371,57 @@ private mismatch(expected: string, currentOperationId: string | null, last: Lane } ``` -`accept` gains one guard (approved): while `this.activeDrive !== undefined`, return `LaneBusy` even when durable state is idle. +`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.** `DriveScope`, `PassPolicy`, and `StepResult` live in `runtime2/types.ts`, which exists from M1. `runtime2/drive.ts` — the **total switch** — is created in **M7**, once every procedure module it imports exists. M3–M6 must not create a switch that imports modules from later milestones; each procedure is called directly from its own test through `createDriveScope()`. Public drive stays guarded until M8. +**Compile ordering.** `Drive` and `StepResult` live in `runtime2/types.ts` from M1. `runtime2/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/runtime2/types.ts — M1. -// Scope and step types live here, not in drive.ts, so progress.ts (M1) and every -// procedure (M2+) can compile before drive.ts exists (M7). -// No import of Lane: DriveScope names LaneDriveCapabilities (§2.11), which is declared -// in this same file. There is therefore no cycle to break, not even a type-only one. - -export interface PassPolicy { - waitForRetry: boolean; - deferredPermits: number; // pass-local; never mutates caller options -} - -export interface DriveScope { - /** The capability set drive may touch — an interface, not the Lane class (§2.11). */ - readonly lane: LaneDriveCapabilities; - readonly operationId: string; - /** The installed ownership record. Admission is always `scope.owner.gate.admit(…)`. */ - readonly owner: ActiveDrive; - readonly context: Context; // installer context, cancellation stripped - readonly pass: PassPolicy; -} - -/** Every procedure returns exactly one of these. */ +// packages/agent/src/harness/runtime2/types.ts export type StepResult = - | { kind: "advance" } // one commit landed; re-read and re-dispatch - | { kind: "reload" } // NO commit; state moved under us; re-read and re-dispatch - | { kind: "waiting"; outcome: DriveOutcome } // durable wait: retry or deferred + | { kind: "advance" } + | { kind: "reload" } + | { kind: "waiting"; outcome: DriveOutcome } | { kind: "settled"; outcome: TerminalOperationOutcome } - | { kind: "lost_ownership" }; // fence no longer names us; this task ends, writing nothing + | { kind: "lost_ownership" }; ``` -Obligations (approved; asserted by `drive-crash-matrix.test.ts`): - | Result | Obligation | |---|---| | `advance` | exactly one commit landed during this step | -| `reload` | **no** commit landed; this is what a declined planner returns | -| `waiting` | the last commit parked the operation in `retry_wait` or `deferred` | +| `reload` | no commit landed; state moved under this planner | +| `waiting` | the last commit parked the operation in retry or deferred wait | | `settled` | the terminal transaction committed | -| `lost_ownership` | no commit landed, and this task must terminate without ever writing again | +| `lost_ownership` | no commit landed; this task terminates and never writes again | -`advance` after a no-commit return is the specific bug this split prevents: it made a declined transition indistinguishable from progress, so the loop could spin without any durable change and without any signal. `reload` is legitimate — queue mutations (`steer`, `followUp`, `nextRun`, `cancelQueued`) run on the same lane line and can move state between our read and our commit — but this package has exactly one writer per operation, so an unbounded run of declines is a bug, not a race. `runDrive` bounds it. +`advance` is created only by `LaneCommand.materialize`, except `foldSteps` may re-emit an existing child `advance`. `runDrive` bounds consecutive `reload` results. ```ts -// packages/agent/src/harness/runtime2/drive.ts — created in M7, when every procedure exists. - -const MAX_CONSECUTIVE_RELOADS = 8; - -/** - * The catch boundary. Admitted provider/tool/gate work can reject with signals the loop - * itself never produces, and each one has a different correct answer. - */ +// packages/agent/src/harness/runtime2/drive.ts — M7 export async function runDrive( - scope: DriveScope, + lane: Lane, drive: Drive, ): Promise { try { - return await drivePass(scope); + return await drivePass(lane, drive); } catch (error) { - // External finalization (harness.md §4.9). An authorized terminal transaction committed - // while this task held an admitted effect; the finalizer already published the end event - // and recorded the outcome on our owner. Resolve from it and publish NOTHING. - if (error instanceof OperationEnded) return hydrateSettled(scope); - - // Durable cancellation won a race with an admitted effect. Ordinary progress, not a - // failure: wait for the marker to land, reload, and reconcile. + if (error instanceof OperationEnded) return hydrateSettled(lane, drive); if (error instanceof AbortRequested) { await error.cancellation; - const current = await loadOperation(scope); - if (current.kind === "lost") return hydrateSettled(scope); - return { - kind: "settled", operationId: scope.operationId, - outcome: await reconcile(scope, current.value), - }; + const current = await loadOperation(lane, drive); + if (current.kind === "lost") return hydrateSettled(lane, drive); + return { kind: "settled", operationId: drive.operationId, + outcome: await reconcile(lane, drive, current.value) }; } - - // Everything else leaves durable state at its last commit and rejects this pass. None of - // them writes, and none is converted into a DriveOutcome here: - // DriveAbandoned the installing invocation was cancelled after admission; the - // operation stays effect_pending and the next drive recovers it - // as an orphan (§2.2). Never a durable cancellation. - // HarnessClosed close sealed admission mid-pass - // HarnessFault an admitted commit failed; the process must restart - // OperationMismatch hydrateSettled found a foreign operation on the fence - throw error; - } -} - -async function drivePass( - scope: DriveScope, -): Promise { - // before_drive must see the operation it is about to drive, and must not run at all for - // an operation that is already cancel_requested — that pass only reconciles. - const initial = await loadOperation(scope); - if (initial.kind === "lost") return hydrateSettled(scope); - if (initial.value.state.control.status !== "cancel_requested") { - await runBeforeDrive(scope, initial.value); - } - - let reloads = 0; - for (;;) { - const current = await loadOperation(scope); - if (current.kind === "lost") return hydrateSettled(scope); - const operation = current.value; - if (operation.state.control.status === "cancel_requested") { - return { kind: "settled", operationId: scope.operationId, outcome: await reconcile(scope, operation) }; - } - - const step = await dispatch(scope, operation); - switch (step.kind) { - case "advance": - reloads = 0; - break; - case "reload": - if (++reloads > MAX_CONSECUTIVE_RELOADS) { - throw new SessionInvariantError(`Drive declined ${reloads} consecutive transitions without progress`); - } - break; - case "waiting": - return step.outcome; - case "settled": - return { kind: "settled", operationId: scope.operationId, outcome: step.outcome }; - case "lost_ownership": - return hydrateSettled(scope); - } - } -} - -function loadOperation( - scope: DriveScope, -): Promise> { - return scope.lane.command(async (_projection, reader) => ({ - kind: "return", - result: await readDurableOperation(reader, scope.lane.name, scope.operationId, scope.context), - }), scope.context); -} - -/** - * The fence stopped naming this operation. Either it terminated (laneLastResult is ours, so - * hydrate the same outcome a live caller would have received) or another operation took the - * lane, which is a real mismatch: this pass ends in error and never writes again. - */ -async function hydrateSettled( - scope: DriveScope, -): Promise { - // Checked before entering a lane job: an in-process finalizer already published this - // operation's end event, so re-deriving one here would duplicate it. A REPLACEMENT-process - // finalizer leaves no local record, and those passes fall through to laneLastResult. - if (scope.owner.finalizedOutcome !== undefined) return scope.owner.finalizedOutcome; - - return scope.lane.command(async (_projection, reader) => { - const [laneStateStored, lastStored] = await Promise.all([ - reader.getValue(laneStateValue(scope.lane.name), scope.context), - reader.getValue(laneLastResult(scope.lane.name), scope.context), - ]); - if (laneStateStored === undefined) { - throw new SessionInvariantError(`Lane ${JSON.stringify(scope.lane.name)} has no lane state`); - } - const last = lastStored?.value; - if (last?.operationId !== scope.operationId) { - const currentId = laneStateStored.value.currentOperationId; - return { kind: "reject", error: scope.lane.mismatch(scope.operationId, currentId, last) }; - } - return { - kind: "return", - result: { kind: "settled", operationId: scope.operationId, - outcome: await settledFromLastResult(reader, last, scope.context) }, - }; - }, scope.context); -} - -async function dispatch( - scope: DriveScope, operation: DurableOperation, -): Promise { - switch (operation.state.kind) { - case "run": return dispatchRun(scope, narrowRun(operation)); - case "compaction": return runStandaloneCompaction(scope, operation, operation.state); - case "navigation": return runNavigation(scope, operation, operation.state); - } -} - -async function dispatchRun( - scope: DriveScope, run: DurableRun, -): Promise { - const phase = run.state.phase; - switch (phase.kind) { - case "starting": return startRun(scope, run); - case "checkpoint": return runCheckpoint(scope, run, phase); - case "assistant": return runGeneration(scope, run, phase.generation); - case "tools": return runToolBatch(scope, run, phase.batch); - case "compaction": return runInRunCompaction(scope, run, phase); - case "deferred": return runDeferred(scope, run, phase.deferred); - case "failure_drain": return finishRun(scope, run, phase.error); + throw error; // DriveAbandoned, HarnessClosed, HarnessFault, OperationMismatch } } ``` +`drivePass` loads durable control before `before_drive`, skips ordinary hooks under `cancel_requested`, then runs one total phase switch. Procedures receive `(lane, drive, durableState)`. `drive.waitForRetry` and mutable `drive.deferredPermits` are pass-local and never mutate caller options. + +`hydrateSettled(lane, drive)` first returns `drive.finalizedOutcome` when present; otherwise it reads matching `laneLastResult`. A foreign current/latest operation produces `OperationMismatch` and no write. + ### 2.5 Transition planner / decision shape — approved `Lane.command` passes `this.state` (the **process-local projection**), not a durable read. Drive transitions must not trust it. @@ -765,8 +571,8 @@ export function projectIdle( Usage inside any drive transition (approved shape; body illustrative). The planner returns a `StepResult` directly, which makes "no `advance` without a commit" structural rather than a rule to remember: ```ts -const step = await scope.lane.command(async (_projection, reader) => { - const read = await readDurableRun(reader, scope.lane.name, scope.operationId, scope.context); +const step = await lane.command(async (_projection, reader) => { + const read = await readDurableRun(reader, lane.name, drive.operationId, drive.context); // Declines are EXPECTED. Lane.command turns a planner throw into a harness fault, so // return an explicit no-commit outcome instead of throwing. if (read.kind === "lost") return { kind: "return", result: { kind: "lost_ownership" } }; @@ -774,17 +580,17 @@ const step = await scope.lane.command(async (_projection, reader) => if (run.state.phase.kind !== "assistant") return { kind: "return", result: { kind: "reload" } }; if (run.state.phase.generation.status !== "ready") return { kind: "return", result: { kind: "reload" } }; if (run.state.phase.generation.context.stepId !== expectedStepId) return { kind: "return", result: { kind: "reload" } }; - if (!scope.lane.ownsDrive(scope)) return { kind: "return", result: { kind: "lost_ownership" } }; + if (!lane.isDriveActive(drive)) return { kind: "return", result: { kind: "lost_ownership" } }; const nextState: RunState = { ...run.state, phase: { kind: "assistant", generation } }; return { kind: "commit", - writes: [setValue(operationStateValue(scope.operationId), nextState)], + writes: [setValue(operationStateValue(drive.operationId), nextState)], next: projectRun(run, nextState), materialize: (): StepResult => ({ kind: "advance" }), - events: () => [{ type: "turn_start", runId: scope.operationId, turnId: stepId, lane: scope.lane.name }], + events: () => [{ type: "turn_start", runId: drive.operationId, turnId: stepId, lane: lane.name }], }; -}, scope.context); +}, drive.context); if (step.kind !== "advance") return step; // reload / lost_ownership propagate unchanged ``` @@ -795,25 +601,25 @@ if (step.kind !== "advance") return step; // reload / lost_ownership propag ```ts // packages/agent/src/harness/runtime2/drive/generation.ts async function runReadyGeneration( - scope: DriveScope, run: DurableRun, gen: Extract, + lane: Lane, drive: Drive, run: DurableRun, gen: Extract, ): Promise { - const config = scope.lane.readConfig(); + const config = lane.readConfig(); const identity = gen.context.configuration.model; - const model = scope.lane.models.getModel(identity.provider, identity.modelId); - if (model === undefined) return enterConfigurationFailure(scope, "model_unavailable", identity); + 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(scope, gen.context, run); - const systemPrompt = await resolveSystemPrompt(config, scope.context); - const patch = await scope.lane.hooks.runWithGate( + 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: scope.lane.name, runId: scope.operationId, model, step: "assistant", attempt: gen.nextAttempt, streamOptions: gen.context.streamOptions }, - scope.owner.gate, scope.context, + { 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 = scope.lane.session.idGenerator.next(at); - const usageId = scope.lane.session.idGenerator.next(at); + const responseEntryId = lane.session.idGenerator.next(at); + const usageId = lane.session.idGenerator.next(at); // ---- intent: one visible commit ---- const generation: Generation = { @@ -823,27 +629,27 @@ async function runReadyGeneration( }; // commitGenerationIntent returns a StepResult, never a boolean. A declined planner // committed nothing, so the only honest answers are `reload` and `lost_ownership`. - const intent = await commitGenerationIntent(scope, gen.context.stepId, generation); + const intent = await commitGenerationIntent(lane, drive, gen.context.stepId, generation); if (intent.kind !== "advance") return intent; // ---- effect: admitted, off the lane line ---- - const progress = openFrameProgress(scope, responseEntryId); + 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) => - scope.owner.gate.admit(() => scope.lane.models.streamSimple(model, aiContext, options)), - observer: frameObserver(scope, progress, responseEntryId), - }, withAbortSignal(scope.owner.gate.signal, scope.context)); + 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(); // rejects if any frame write failed // ---- outcome: one visible commit ---- - return settleAssistant(scope, { responseEntryId, usageId, stepId: gen.context.stepId }, response); + return settleAssistant(lane, drive, { responseEntryId, usageId, stepId: gen.context.stepId }, response); } ``` @@ -860,18 +666,16 @@ Grep guard for the final review: every `kind: "advance"` outside `runtime2/types ```ts // packages/agent/src/harness/runtime2/progress.ts export interface ProgressChannel { - write(item: T): void; // synchronous enqueue; never awaited per item + write(item: T): void; seal(): void; - drain(): Promise; // rejects if any retained write rejected - clearWrite(): Write; // the delete included in the settling transaction + drain(): Promise; + clearWrite(): Write; } function openProgress( - scope: DriveScope, + lane: Lane, drive: Drive, commitWrite: (item: T) => Write, clear: () => Write, - // Must fence BOTH durable identity and owner object identity (§2.1 ABA): a replacement - // owner can hold the same operationId after an abandoned invocation released the lane. stillOwns: (reader: SessionReader) => Promise, ): ProgressChannel { let sealed = false; @@ -879,42 +683,22 @@ function openProgress( return { write(item) { if (sealed) return; - const write = scope.lane.command(async (_projection, reader) => { + const write = lane.command(async (projection, reader) => { if (!(await stillOwns(reader))) return { kind: "return", result: undefined }; - return { kind: "commit", writes: [commitWrite(item)], next: /* unchanged projection */ …, - materialize: () => undefined }; - }, scope.context); - latest = write; // retained RAW: drain() must still reject - void write.catch(() => {}); // separate observer only silences the warning + return { kind: "commit", writes: [commitWrite(item)], next: projection, + materialize: () => undefined }; + }, drive.context); + latest = write; // raw rejecting promise retained for drain + void write.catch(() => {}); // separate harness-fault observer }, seal() { sealed = true; }, async drain() { await latest; }, clearWrite: clear, }; } - -export const openFrameProgress = ( - scope: DriveScope, responseEntryId: string, -) => - openProgress( - scope, - (frame) => appendList(pendingAssistantFrames(scope.operationId, responseEntryId), frame), - () => deleteList(pendingAssistantFrames(scope.operationId, responseEntryId)), - /* stillOwns: ownsDrive(scope) && phase assistant + effect_pending + same responseEntryId */ …, - ); - -export const openToolProgress = ( - scope: DriveScope, invocationId: string, -) => - openProgress>( - scope, - (snapshot) => setValue(pendingToolOutput(scope.operationId, invocationId), snapshot), - () => deleteValue(pendingToolOutput(scope.operationId, invocationId)), - /* stillOwns: ownsDrive(scope) && phase tools + this call effect_pending */ …, - ); ``` -`latest` keeps the **rejecting** promise so `drain()` propagates the failure; the `void write.catch` observer must not replace it. +`openFrameProgress(lane, drive, responseEntryId)` and `openToolProgress(lane, drive, invocationId)` fence both the durable phase/id and `lane.isDriveActive(drive)`. The provider/tool callback enqueues synchronously, settlement seals then drains, and the settling transaction includes `clearWrite()`. ### 2.8 Tool batch — approved shape, illustrative bodies @@ -922,13 +706,13 @@ export const openToolProgress = ( // packages/agent/src/harness/runtime2/drive/tools.ts // Every child procedure returns a StepResult. None of them may invent `advance`. async function runToolBatch( - scope: DriveScope, run: DurableRun, batch: ToolBatch, + lane: Lane, drive: Drive, run: DurableRun, batch: ToolBatch, ): Promise { const steps: StepResult[] = []; // A crash can leave staged outcome_ready calls with no live task. Nothing else // places them, so materialize any existing ready prefix before starting work. - const prefix = await materializeReadyPrefix(scope, batch.turnId); + const prefix = await materializeReadyPrefix(lane, drive, batch.turnId); steps.push(prefix); // Short-circuit BEFORE resolving toolContext or starting any call. If we already lost the // fence there is nothing to run; if the state moved, re-dispatch reads it fresh. Starting @@ -937,14 +721,14 @@ async function runToolBatch( const sequential = run.state.settings.toolExecution === "sequential"; const started = new Map>(); // process-local, never durable - const toolContext = await resolveToolContext(scope); // once per batch, not per call + const toolContext = await resolveToolContext(lane, drive); // once per batch, not per call try { for (const call of batch.calls) { if (call.status === "completed" || call.status === "outcome_ready") continue; const work = call.status === "effect_pending" - ? recoverCall(scope, batch, call, toolContext) // orphan by construction - : runCall(scope, batch, call, toolContext); + ? recoverCall(lane, drive, batch, call, toolContext) // orphan by construction + : runCall(lane, drive, batch, call, toolContext); started.set(call.sourceIndex, work); if (sequential) { const step = await work; @@ -964,12 +748,12 @@ async function runToolBatch( for (const settled of await Promise.allSettled(started.values())) { if (settled.status === "fulfilled") steps.push(settled.value); } - steps.push(await stageUnstartedCalls(scope, batch)); + steps.push(await stageUnstartedCalls(lane, drive, batch)); } // Skip the closing materialization once ownership is gone: it would only decline anyway, // and `lost_ownership` must reach runDrive without another lane job. if (!steps.some((step) => step.kind === "lost_ownership")) { - steps.push(await materializeReadyPrefix(scope, batch.turnId)); + steps.push(await materializeReadyPrefix(lane, drive, batch.turnId)); } return foldSteps(steps); } @@ -1017,36 +801,36 @@ The terminal transaction is shared by runs, standalone compactions, and navigati ```ts // packages/agent/src/harness/runtime2/drive/terminal.ts async function commitTerminal( - scope: DriveScope, request: TerminalRequest, + lane: Lane, drive: Drive, request: TerminalRequest, ): Promise { - return scope.lane.command(async (_projection, reader) => { - const read = await readDurableOperation(reader, scope.lane.name, scope.operationId, scope.context); + return lane.command(async (_projection, reader) => { + const read = await readDurableOperation(reader, lane.name, drive.operationId, drive.context); // The owner reached its own terminal transaction; losing the fence here is corruption. if (read.kind === "lost") throw new SessionInvariantError("terminal transaction lost its operation"); const operation = read.value; const [toolArgs, toolMemos, preparations, toolOutputs] = await Promise.all([ - reader.scanValues(operationToolArgsPrefix(scope.operationId), scope.context), - reader.scanValues(operationToolMemoPrefix(scope.operationId), scope.context), - reader.scanValues(operationPreparationPrefix(scope.operationId), scope.context), - reader.scanValues(pendingToolOutputPrefix(scope.operationId), scope.context), + reader.scanValues(operationToolArgsPrefix(drive.operationId), drive.context), + reader.scanValues(operationToolMemoPrefix(drive.operationId), drive.context), + reader.scanValues(operationPreparationPrefix(drive.operationId), drive.context), + reader.scanValues(pendingToolOutputPrefix(drive.operationId), drive.context), ]); const writes: Write[] = [ - deleteValue(operationMeta(scope.operationId)), - deleteValue(operationState(scope.operationId)), + deleteValue(operationMeta(drive.operationId)), + deleteValue(operationState(drive.operationId)), ...toolArgs.map((v) => deleteValue(v.address)), ...toolMemos.map((v) => deleteValue(v.address)), ...preparations.map((v) => deleteValue(v.address)), ...toolOutputs.map((v) => deleteValue(v.address)), ...liveFrameListWrites(operation.state), // run only: exact deleteList for an unsettled response ...stagedPendingEntryWrites(operation.state), // run only: staged results + queued/drained ids - setValue(laneLastResult(scope.lane.name), lastResult), - setValue(laneStateValue(scope.lane.name), { currentOperationId: null, pendingNextRun: operation.pendingNextRun }), + setValue(laneLastResult(lane.name), lastResult), + setValue(laneStateValue(lane.name), { currentOperationId: null, pendingNextRun: operation.pendingNextRun }), ]; return { kind: "commit", writes, next: projectIdle(operation, lastResult), materialize: () => outcome, events: () => [endEvent] }; - }, scope.context); + }, drive.context); } ``` @@ -1176,89 +960,30 @@ const reopened = await repo.open(session.metadata, ctx); Determinism: `MemoryStorage` accepts `now`; `Session.idGenerator` must become injectable (M1 API change) or tests compare under first-appearance id normalization. -### 2.11 Generic parameter discipline — approved +### 2.11 Direct Lane procedure access and generic discipline — approved -Two problems, one fix. `Lane` is not generic, so it cannot name `Config` or `AgentHarnessTool`. And `session`/`models` are `protected` while `emitBatch` is `private`, so a procedure in `drive/generation.ts` — a different file, not a subclass — cannot reach any of them. TypeScript has no package-private modifier, and widening them to public is both a bigger surface than drive needs and unenforceable afterwards. - -The fix is an explicit capability interface that `Lane` implements, with `DriveScope.lane` typed as **the interface, not the class**. Procedures then cannot reach anything not listed here — the compiler enforces the boundary — `createDriveScope()` can build a minimal fake without constructing a whole `Lane`, and `runtime2/types.ts` stops importing `Lane` at all, so the type-only cycle disappears. +Drive procedures are package-internal and receive the concrete `Lane` plus `Drive`. There is no capability interface or dependency bundle. ```ts -// packages/agent/src/harness/runtime2/types.ts +// every drive procedure +import type { Lane } from "../lane.ts"; +import type { Drive, StepResult } from "../types.ts"; -/** - * Exactly what a drive procedure may touch. Lane implements it; nothing else on Lane - * is reachable from drive/**. Not exported from the package (see below), so this widens - * no published API. - */ -export interface LaneDriveCapabilities { - readonly name: string; - /** Reads outside the lane line (context projection) and `idGenerator`. */ - readonly session: Session; - readonly models: Models; - readonly hooks: HookRegistry; // HookRegistry is NOT generic today - /** - * The one write path: the signature Lane.command already has. `LaneCommand` is today a - * private, non-exported type in lane.ts, so it MOVES here in M1 — exporting it from - * lane.ts instead would recreate exactly the import this interface exists to remove. - */ - command( - plan: (projection: LaneState, reader: SessionReader) => LaneCommand | Promise>, - context: Context, - ): Promise; - /** Process-local observation outside a commit (`tool_update`). */ - emitBatch(events: HarnessEvent[], context: Context): Promise; - /** The full config, not just the acceptance subset. */ - readConfig(): Config; - /** True while this scope is still the installed owner. */ - ownsDrive(scope: DriveScope): boolean; - mismatch(expected: string, currentOperationId: string | null, last: LaneLastResult | undefined): OperationMismatch; -} - -// packages/agent/src/harness/runtime2/lane.ts -export class Lane - implements AgentLane, LaneDriveCapabilities { - // `protected` -> `readonly`, and `private emitBatch` -> `emitBatch`, exactly and only - // for the members named above. Everything else keeps its current visibility. - readonly session: Session; - readonly models: Models; - readonly hooks: HookRegistry; - state: LaneState; - // … - readConfig(): Config { … } - ownsDrive(scope: DriveScope): boolean { return this.activeDrive === scope.owner; } -} - -// packages/agent/src/harness/runtime2/harness.ts -export class Harness - extends Lane - implements AgentHarness { … } - -// every drive procedure and helper async function runCheckpoint( - scope: DriveScope, run: DurableRun, phase: Extract, -): Promise { … } + lane: Lane, drive: Drive, run: DurableRun, + phase: Extract, +): Promise { /* ... */ } ``` -The import direction is safe in both directions: `types.ts` names `Gate`/`ActiveDrive` from `execution/effect-gate.ts` and the durable types from `session/`, and neither of those trees imports `runtime2` (verified: `grep -rn runtime2 src/harness/{execution,session}` is empty). So `types.ts` depends on no `runtime2` module, and `lane.ts` depends on `types.ts` — one direction, no cycle, nothing type-only about it. - -**The published API is unaffected**, and this is checkable: `packages/agent/src/index.ts` exports neither `runtime2/lane.ts` nor `runtime2/harness.ts`. Consumers hold the `AgentLane` / `AgentHarness` interfaces from `agent-harness.ts`, which gain nothing. Verify with `grep -n "runtime2" packages/agent/src/index.ts` before and after — it must stay empty. +`Lane` already exposes the package-internal members procedures need: `session`, `models`, `hooks`, `command`, `emitBatch`, `readConfig`, `mismatch`, and `isDriveActive`. 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, matching `Config` and `AgentHarnessOptions`. No other bound. -- **No `any`.** No `Lane`, no `DriveScope`, no `as unknown as` bridge, no `@ts-expect-error`. -- `Lane` is **invariant** in `TContext` (`Config.toolContext` is both produced and consumed). Never widen through `Lane` to make an assignment compile; add the type parameter to the function instead. -- Tests instantiate concrete contexts (`Lane`, `Lane<{ cwd: string }>`); `createDriveScope()` is generic and infers from the fixture. -- Tool update options use the exact literal type, because `checkpoint: false` and an absent flag mean the same thing and only `true` carries a request: - ```ts - // packages/agent/src/harness/types.ts - export interface AgentHarnessToolUpdateOptions { checkpoint?: true } - export type AgentHarnessToolUpdateCallback = ( - result: AgentToolResult, - options?: AgentHarnessToolUpdateOptions, - ) => void; - ``` - -Files this changes: `runtime2/lane.ts` (class + every internal signature), `runtime2/harness.ts` (`extends Lane`, `buildLane` returns `Lane`), `runtime2/types.ts` (`DriveScope`), `runtime2/restore.ts` if it names `Lane`, `harness/types.ts` (update options/callback), and the test utilities in `test/harness/runtime2/test-utils.ts`. Tests that construct a `Lane` today must name a concrete context argument. +- `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 @@ -1334,9 +1059,9 @@ const usageRow: Omit = { id: usageId, usage: response.usage, en const writes: Write[] = [ insertEntry(responseEntry), // 0 insertUsage(usageRow), // 1 - setValue(laneLeaf(scope.lane.name), responseEntryId), // 2 - deleteList(pendingAssistantFrames(scope.operationId, responseEntryId)), - setValue(operationStateValue(scope.operationId), nextState), + setValue(laneLeaf(lane.name), responseEntryId), // 2 + deleteList(pendingAssistantFrames(drive.operationId, responseEntryId)), + setValue(operationStateValue(drive.operationId), nextState), ]; const ENTRY_WRITE = 0; const USAGE_WRITE = 1; @@ -1345,9 +1070,9 @@ return { kind: "commit", writes, next: projectRun(run, nextState), materialize: (): StepResult => ({ kind: "advance" }), events: (commit) => [ - { type: "entry_added", lane: scope.lane.name, + { type: "entry_added", lane: lane.name, entry: { ...responseEntry, seq: commit.seqs[ENTRY_WRITE], timestamp: commit.timestamp } }, - { type: "usage", lane: scope.lane.name, + { type: "usage", lane: lane.name, row: { ...usageRow, seq: commit.seqs[USAGE_WRITE] }, totals: commit.stats.usage }, // exact totals as of THIS commit, synchronously ], @@ -1373,7 +1098,7 @@ Every milestone uses the same subsections. Public drive stays disabled until M8. ### Guard that keeps public drive disabled (M1–M7) -`runtime2/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 only through `createDriveScope()` in `test/harness/runtime2/test-utils.ts` against directly constructed durable state. **M8 removes those throws in one commit**, after every phase and reconciliation path exists. +`runtime2/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. --- @@ -1428,7 +1153,7 @@ grep -rn "Breakpoint\|action_required\|ActionInfo\|ActionRequired\|peekAction\|e ``` Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `highlight.min.js` may match. -**Do not implement yet.** No `ActiveDrive`, no `Gate`, no drive procedures. +**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? @@ -1462,8 +1187,9 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig |---|---| | `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/runtime2/types.ts` | add `ActiveDrive` (incl. mutable `finalizedOutcome`), `ActiveDriveHandle`, `createActiveDrive` (§2.1); add `LaneDriveCapabilities` (§2.11), `DriveScope`, `PassPolicy`, **`StepResult`** (§2.4) so `progress.ts` and every procedure can import them before `drive.ts` exists (M7); **move `LaneCommand` here** from `lane.ts`, where it is currently private and unexported. `DriveScope.lane` is the **interface**, so `types.ts` imports no `runtime2` module at all | -| `src/harness/runtime2/lane.ts` | make the class `Lane implements AgentLane, LaneDriveCapabilities` (§2.11); import `LaneCommand` from `types.ts` instead of declaring it; change `session`/`models` from `protected` to `readonly` and `emitBatch` from `private`, **only** for the members the interface names; add `hooks: HookRegistry` constructor parameter and field; add `private activeDrive: ActiveDrive \| undefined`; add `ownsDrive(scope)`; replace `getAcceptanceConfig: () => AcceptanceConfig` with `readConfig(): Config`; make `mismatch` non-private; `startDrive` removes the owner in a `finally` (§2.1) | +| `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/runtime2/types.ts` | add the `Drive` class (completion, gate control, stripped pass Context, installer signal, wait policy, mutable `deferredPermits`, mutable `finalizedOutcome`) and `StepResult`; move `LaneCommand` here from `lane.ts` | +| `src/harness/runtime2/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/runtime2/harness.ts` | `Harness extends Lane`; `buildLane` returns `Lane`; pass `HookRegistry` and full `Config` into `Lane` | | `src/harness/runtime2/restore.ts` | name `Lane` wherever it names `Lane` | | `src/harness/session/types.ts` | add `stats: SessionStats` to `CommitResult` (§2.12) | @@ -1477,16 +1203,16 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig | `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/runtime2/test-utils.ts` | add generic `createDriveScope()` and `createDriveFixture()`; name concrete contexts where a `Lane` is constructed | +| `test/harness/runtime2/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`/`DriveScope` are generic in `TContext` with no `any`. +**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 `LaneDriveCapabilities` | +| `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` @@ -1547,7 +1273,7 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig - `src/harness/runtime2/drive/generation.ts` — `runGeneration`, `settleAssistant`, `enterConfigurationFailure` (§2.6) - `test/harness/runtime2/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. `StepResult` and `DriveScope` already live in `runtime2/types.ts` (M1), so procedures compile and are tested standalone. `drive.ts` is created once, in M7. +**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. `StepResult` and `Drive` already live in `runtime2/types.ts` (M1), so procedures compile and are tested standalone. `drive.ts` is created once, in M7. **Modify** | Path | Change | @@ -1560,7 +1286,7 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig **Focused tests** | Path | Theme | |---|---| -| `test/harness/runtime2/drive-generation.test.ts` | each procedure invoked directly through `createDriveScope()`, no switch; intent before provider; reserved-id settlement; frame order without storage awaits; orphan recovery without provider call; mixed projecting/unprojected drains (leaf vs trigger); queued public mutation observed by the next planner → procedure returns `reload` and commits nothing; fence taken by another operation → returns `lost_ownership` and commits nothing; published projection equals fresh restore | +| `test/harness/runtime2/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 (leaf vs trigger); queued public mutation observed by the next planner → procedure returns `reload` 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 @@ -1587,7 +1313,7 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig | Path | Change | |---|---| | `src/harness/runtime2/drive/generation.ts` | retry classification → `retry_wait`; `waitForRetry` policy | -| `src/harness/runtime2/drive/deferred.ts` | `PassPolicy.deferredPermits` consumption lives with the deferred procedure. **Do not touch `drive.ts`** — it does not exist until M7, which wires the same `scope.pass` values into the switch | +| `src/harness/runtime2/drive/deferred.ts` | `drive.deferredPermits` consumption lives with the deferred procedure. **Do not touch `drive.ts`** — it does not exist until M7 | **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. @@ -1700,15 +1426,15 @@ So the drive runner composes the same four, with the signal and telemetry parent // packages/agent/src/harness/runtime2/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 = scope.lane.session.idGenerator.next(); - const intent = await commitRequestIntent(scope, taskId, index, usageId); + const usageId = lane.session.idGenerator.next(); + const intent = await commitRequestIntent(lane, drive, taskId, index, usageId); // Carries the planner's own StepResult; the attempt loop converts it straight back. if (intent.kind !== "advance") 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(scope.owner.gate.signal, context); + const admitted = withAbortSignal(drive.gate.signal, context); const requestOptions: SimpleStreamOptions = { ...options, signal: admitted.abortSignal, @@ -1719,10 +1445,10 @@ const request: NestedRequest = async (label, aiContext, options, context) => { // 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 scope.owner.gate.admit(() => - scope.lane.models.completeSimple(model, aiContext, requestOptions)); + const message = await drive.gate.admit(() => + lane.models.completeSimple(model, aiContext, requestOptions)); - await commitRequestUsage(scope, taskId, usageId, message.usage); + await commitRequestUsage(lane, drive, taskId, usageId, message.usage); return message; }; ``` @@ -1754,16 +1480,16 @@ class StructuralAttemptDeclined extends Error { /** Narrower than StepResult: an intent commit can only advance, reload, or lose the fence. */ type IntentResult = Extract; declare function commitRequestIntent( - scope: DriveScope, taskId: string, index: number, usageId: string, + lane: Lane, drive: Drive, taskId: string, index: number, usageId: string, ): Promise; async function runStructuralAttempt( - scope: DriveScope, operation: DurableOperation, decision: StructuralDecision, + lane: Lane, drive: Drive, operation: DurableOperation, decision: StructuralDecision, ): Promise { try { - const result = await compact(preparation, scope.lane.models, model, customInstructions, - thinkingLevel, /* retry */ undefined, /* callbacks */ undefined, scope.context, request); - return await publishStructuralResult(scope, operation, decision, result); + 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) { // The one signal this module throws for a no-commit decline. Hand the planner's own // StepResult back to runDrive, which re-reads and re-dispatches. @@ -1827,54 +1553,36 @@ The parameter is optional and defaulted precisely so existing callers are untouc **Modify** | Path | Change | |---|---| -| `src/harness/runtime2/lane.ts` | `seal` closes the owner's gate and removes it process-locally. **`Lane.requestAbort` keeps its `SliceNotImplemented`**: the primitive it would call exists now, but the public method must not be reachable while `drive` is still guarded, or a caller could durably cancel an operation nothing can drive. M8 exposes it | +| `src/harness/runtime2/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/runtime2/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/runtime2/lane.ts -// notifyOwnerOfFinalization is called by the package-internal terminal-transaction helper -// (harness.md §4.9) in the SAME continuation that observes the terminal commit, after the -// terminal events were published. abandonOwner is called from the invocation-cancellation -// path in §2.2. Both are private to Lane; neither is a public surface. -private notifyOwnerOfFinalization(operationId: string, outcome: TerminalOperationOutcome): void { - const owner = this.activeDrive; - if (owner === undefined || owner.operationId !== operationId) return; +/** Package-internal M7 lifecycle seam; not part of AgentLane. */ +notifyOwnerOfFinalization(operationId: string, outcome: TerminalOperationOutcome): void { + const drive = this.activeDrive; + if (drive === undefined || drive.operationId !== operationId) return; - // Record BEFORE closing: a cooperative task resolves through runDrive's OperationEnded arm - // using this value, and must not publish a duplicate end event. const settled: DriveOutcome = { kind: "settled", operationId, outcome }; - owner.finalizedOutcome = settled; - - // Close admission and pull the AbortController, so cooperative work stops. - owner.control.close(new OperationEnded()); - - // Settle the public completion NOW, on the finalizer's outcome. A non-cooperative provider - // or tool must not delay the caller: the terminal transaction already committed and its - // events are already published, so there is nothing left to wait for. - owner.supervisor?.settleCompletion({ kind: "ok", outcome: settled }); - - // RETAIN the record — the other axis. Acceptance keeps seeing LaneBusy until the detached - // task reaches its own exact-object cleanup, even though durable state is already idle - // (harness.md §4.9). - owner.supervisor?.retainOwner(); + drive.finalizedOutcome = settled; // before OperationEnded can reach runDrive + drive.closeGate(new OperationEnded()); + drive.settle(settled); // public completion does not wait on stuck work + // Deliberately do not removeDrive(drive): acceptance remains LaneBusy until pass finally. } -/** - * Invocation abandonment: settle only this invocation's public drive, and release the lane - * immediately — the opposite pairing to finalization. - */ -private abandonOwner(active: ActiveDrive, error: DriveAbandoned): void { - active.control.close(error); - active.supervisor?.settleCompletion({ kind: "error", error }); - // Independent of the pass. Durable state stays at its last commit (effect_pending), and the - // next drive must be able to claim it and run orphan recovery even if the old effect never - // returns. The old task stays observed and is fenced by ownsDrive (§2.1 ABA). - active.supervisor?.removeOwner(); +/** 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 } ``` +Both methods run on `Lane`; procedures do not remove ownership. + `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 write is fenced by `readDurableOperation`, which now returns `lost` for it. **Behavior/invariants landed.** `requestOperationAbort` order: `beginAbort` → interrupt → commit marker → `signalAbort`. `runDrive` loads the 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 notifies the owner synchronously after the terminal commit; the old task stops without writing, resolves from `finalizedOutcome` (falling back to `laneLastResult` when it was finalized by a replacement process), and emits no duplicate terminal event. @@ -1882,10 +1590,10 @@ private abandonOwner(active: ActiveDrive, error: DriveAbandoned): void { **Focused tests** | Path | Theme | |---|---| -| `test/harness/runtime2/drive-reconcile.test.ts` | `requestOperationAbort` exercised directly through `createDriveScope()`, 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/runtime2/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/runtime2/drive-switch.test.ts` | `before_drive` runs once, after the operation is loaded; `before_drive` is **not** invoked for an already `cancel_requested` operation; `reload` re-dispatches without a commit; `MAX_CONSECUTIVE_RELOADS` consecutive declines fault; `lost_ownership` ends the pass with zero further commits; external finalization → `hydrateSettled` returns the same outcome; foreign operation on the fence → `OperationMismatch` | | `test/harness/runtime2/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/runtime2/drive-lifecycle.test.ts` (finalization) | finalize while a **provider stream** is active, and again while a **tool** is active: the owner's gate closes and its signal fires within the finalizing continuation; the admitted effect rejects with `OperationEnded`, `runDrive`'s catch resolves from `owner.finalizedOutcome` **without entering a lane job**, and the task writes nothing afterwards (asserted through `InstrumentedStorage`: zero commit attempts after the terminal one); exactly **one** `run_end` is delivered; `accept` returns `LaneBusy` until the owner's `finally` removes it, then succeeds; a non-cooperative effect that ignores the signal delays nothing and produces no unhandled rejection | +| `test/harness/runtime2/drive-lifecycle.test.ts` (finalization) | finalize while a **provider stream** is active, and again while a **tool** is active: the owner's gate closes and its signal fires within the finalizing continuation; the admitted effect rejects with `OperationEnded`, `runDrive`'s catch resolves from `drive.finalizedOutcome` **without entering a lane job**, and the task writes nothing afterwards (asserted through `InstrumentedStorage`: zero commit attempts after the terminal one); exactly **one** `run_end` is delivered; `accept` returns `LaneBusy` until the owner's `finally` removes it, then succeeds; a non-cooperative effect that ignores the signal delays nothing and produces no unhandled rejection | | `test/harness/runtime2/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) | **Commands.** `npm run check` · targeted vitest @@ -1913,7 +1621,7 @@ private abandonOwner(active: ActiveDrive, error: DriveAbandoned): void { **Modify** | Path | Change | |---|---| -| `src/harness/runtime2/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 `drive` (§2.3), `startDrive`, `removeOwner`; `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/runtime2/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/runtime2/restore.ts` | restore every phase projection | | `src/harness/telemetry.ts` | drive spans | | `src/harness/runtime2/types.ts` | `SliceNotImplemented` retained only for `watchSession` | @@ -1925,9 +1633,9 @@ private abandonOwner(active: ActiveDrive, error: DriveAbandoned): void { **Focused tests** | Path | Theme | |---|---| -| `test/harness/runtime2/drive-ownership.test.ts` | join before hydration; same-id join observes delivered events; mismatch ids. **Completion adapter, asserted identically for join and install**: normal outcome → `Result.ok`; `OperationMismatch` and `Closed` → `Result.err`; `HarnessClosed`, `HarnessFault`, `DriveAbandoned` → the promise **rejects**, never converted to `Result.err` | -| `test/harness/runtime2/drive-ownership.test.ts` (supervisor axes) | run each case with a **non-cooperative provider** and again with a **non-cooperative tool** that ignores its signal and never settles. **External finalization**: the public completion resolves immediately 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 `settleCompletion`/`removeOwner` called twice act once | -| `test/harness/runtime2/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 `ownsDrive` even though `readDurableOperation` returns `ok`; 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/runtime2/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/runtime2/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**: the public completion resolves immediately 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/runtime2/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 `readDurableOperation` returns `ok`; 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/runtime2/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/runtime2/drive-crash-matrix.test.ts` | release N commits → discard → reopen → drive, for every N in every phase path; exactly one commit per `advance` and **zero** per `reload`; gated vs ungated byte-identical writes | @@ -1975,6 +1683,7 @@ Milestone in parentheses where creation order matters. **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` diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 240bcbf36..7a1f0767b 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -15,7 +15,43 @@ import type { AgentMessage, AgentToolResult, QueueMode, ThinkingLevel } from ".. import type { BranchPreparation, BranchSummaryResult } from "./compaction/branch-summarization.ts"; import type { CompactionPreparation, CompactionSettings, CompactResult } from "./compaction/compaction.ts"; import type { Context } from "./context.ts"; -import { type Result, TaggedError } from "./result.ts"; +import type { + Closed, + InvalidLane, + InvalidMessage, + InvalidNavigation, + LaneBusy, + LaneExists, + NoActiveOperation, + NoActiveRun, + NothingToCompact, + NothingToResume, + OperationMismatch, + Result, + UnknownSkill, + UnknownTarget, + UnknownTemplate, +} from "./result.ts"; + +export { + Closed, + HarnessClosed, + HarnessFault, + InvalidLane, + InvalidMessage, + InvalidNavigation, + LaneBusy, + LaneExists, + NoActiveOperation, + NoActiveRun, + NothingToCompact, + NothingToResume, + OperationMismatch, + UnknownSkill, + UnknownTarget, + UnknownTemplate, +} from "./result.ts"; + import { createAgentHarness } from "./runtime2/harness.ts"; import type { BranchSummaryEntry, @@ -40,61 +76,6 @@ import type { Skill, } from "./types.ts"; -export class LaneBusy extends TaggedError("LaneBusy")<{ - lane: string; - operationId: string; - operationKind: "run" | "compaction" | "navigation"; - message: string; -}> {} -export class OperationMismatch extends TaggedError("OperationMismatch")<{ - lane: string; - expectedOperationId: string; - currentOperationId?: string; - lastOperationId?: string; - message: string; -}> {} -export class NoActiveRun extends TaggedError("NoActiveRun")<{ lane: string; message: string }> {} -export class NoActiveOperation extends TaggedError("NoActiveOperation")<{ lane: string; message: string }> {} -export class NothingToResume extends TaggedError("NothingToResume")<{ lane: string; message: string }> {} -export class NothingToCompact extends TaggedError("NothingToCompact")<{ lane: string; message: string }> {} -export class InvalidMessage extends TaggedError("InvalidMessage")<{ - lane: string; - reason: string; - message: string; -}> {} -export class InvalidNavigation extends TaggedError("InvalidNavigation")<{ - lane: string; - reason: string; - message: string; -}> {} -export class UnknownSkill extends TaggedError("UnknownSkill")<{ name: string; message: string }> {} -export class UnknownTemplate extends TaggedError("UnknownTemplate")<{ name: string; message: string }> {} -export class UnknownTarget extends TaggedError("UnknownTarget")<{ targetId: string; message: string }> {} -export class LaneExists extends TaggedError("LaneExists")<{ lane: string; message: string }> {} -export class InvalidLane extends TaggedError("InvalidLane")<{ - lane: string; - reason: string; - message: string; -}> {} -export class Closed extends TaggedError("Closed")<{ message: string }> {} - -export class HarnessFault extends Error { - readonly cause: unknown; - - constructor(message: string, cause: unknown) { - super(message); - this.name = "HarnessFault"; - this.cause = cause; - } -} - -export class HarnessClosed extends Error { - constructor() { - super("AgentHarness was closed while the operation was active"); - this.name = "HarnessClosed"; - } -} - export type OptionalFinalAssistant = | { finalEntryId: string; finalMessage: AssistantMessage } | { finalEntryId?: never; finalMessage?: never }; diff --git a/packages/agent/src/harness/result.ts b/packages/agent/src/harness/result.ts index d4bfda4f4..79120a25e 100644 --- a/packages/agent/src/harness/result.ts +++ b/packages/agent/src/harness/result.ts @@ -50,6 +50,61 @@ export function TaggedError(tag: Tag): TaggedErrorFactory; } +export class LaneBusy extends TaggedError("LaneBusy")<{ + lane: string; + operationId: string; + operationKind: "run" | "compaction" | "navigation"; + message: string; +}> {} +export class OperationMismatch extends TaggedError("OperationMismatch")<{ + lane: string; + expectedOperationId: string; + currentOperationId?: string; + lastOperationId?: string; + message: string; +}> {} +export class NoActiveRun extends TaggedError("NoActiveRun")<{ lane: string; message: string }> {} +export class NoActiveOperation extends TaggedError("NoActiveOperation")<{ lane: string; message: string }> {} +export class NothingToResume extends TaggedError("NothingToResume")<{ lane: string; message: string }> {} +export class NothingToCompact extends TaggedError("NothingToCompact")<{ lane: string; message: string }> {} +export class InvalidMessage extends TaggedError("InvalidMessage")<{ + lane: string; + reason: string; + message: string; +}> {} +export class InvalidNavigation extends TaggedError("InvalidNavigation")<{ + lane: string; + reason: string; + message: string; +}> {} +export class UnknownSkill extends TaggedError("UnknownSkill")<{ name: string; message: string }> {} +export class UnknownTemplate extends TaggedError("UnknownTemplate")<{ name: string; message: string }> {} +export class UnknownTarget extends TaggedError("UnknownTarget")<{ targetId: string; message: string }> {} +export class LaneExists extends TaggedError("LaneExists")<{ lane: string; message: string }> {} +export class InvalidLane extends TaggedError("InvalidLane")<{ + lane: string; + reason: string; + message: string; +}> {} +export class Closed extends TaggedError("Closed")<{ message: string }> {} + +export class HarnessFault extends Error { + readonly cause: unknown; + + constructor(message: string, cause: unknown) { + super(message); + this.name = "HarnessFault"; + this.cause = cause; + } +} + +export class HarnessClosed extends Error { + constructor() { + super("AgentHarness was closed while the operation was active"); + this.name = "HarnessClosed"; + } +} + export type ErrorMatchers, TValue> = { [Tag in TError["_tag"]]: (error: Extract) => TValue; }; diff --git a/packages/agent/src/harness/runtime2/harness.ts b/packages/agent/src/harness/runtime2/harness.ts index ff5dbb052..33072b8b0 100644 --- a/packages/agent/src/harness/runtime2/harness.ts +++ b/packages/agent/src/harness/runtime2/harness.ts @@ -10,14 +10,13 @@ import type { OpenOperation, Resources, } from "../agent-harness.ts"; -import { Closed, HarnessClosed, HarnessFault, InvalidLane, LaneExists, UnknownTarget } from "../agent-harness.ts"; import { type CompactionSettings, DEFAULT_COMPACTION_SETTINGS } from "../compaction/compaction.ts"; import { DEFAULT_RETRY_POLICY, validateCompactionSettings, validateRetryPolicy, validateToolNames } from "../config.ts"; import type { Context } from "../context.ts"; import { HarnessEventBus } from "../events.ts"; import { HookRegistry } from "../hooks.ts"; import { convertToLlm } from "../messages.ts"; -import { Result } from "../result.ts"; +import { Closed, HarnessClosed, HarnessFault, InvalidLane, LaneExists, Result, UnknownTarget } from "../result.ts"; import { SessionInvalidLaneError, SessionInvariantError, diff --git a/packages/agent/src/harness/runtime2/lane.ts b/packages/agent/src/harness/runtime2/lane.ts index a7bba33dc..7e18838d9 100644 --- a/packages/agent/src/harness/runtime2/lane.ts +++ b/packages/agent/src/harness/runtime2/lane.ts @@ -6,29 +6,31 @@ import { reduceAssistantMessageFrames, } from "@earendil-works/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; -import { - type AgentLane, - Closed, - HarnessClosed, - type HarnessEvent, - InvalidMessage, - LaneBusy, - type LaneConfigEventPayload, - type LaneExecutionInfo, - type LaneSnapshot, - type ModelIdentity, - type OperationAdmissionResult, - OperationMismatch, - type OperationRequest, - type QueuedItem, - UnknownSkill, - UnknownTemplate, - type WatchHandle, +import type { + AgentLane, + HarnessEvent, + LaneConfigEventPayload, + LaneExecutionInfo, + LaneSnapshot, + ModelIdentity, + OperationAdmissionResult, + OperationRequest, + QueuedItem, + WatchHandle, } from "../agent-harness.ts"; import type { Context } from "../context.ts"; import type { HookRegistry } from "../hooks.ts"; import { formatPromptTemplateInvocation } from "../prompt-templates.ts"; -import { Result } from "../result.ts"; +import { + Closed, + HarnessClosed, + InvalidMessage, + LaneBusy, + OperationMismatch, + Result, + UnknownSkill, + UnknownTemplate, +} from "../result.ts"; import { insertEntry } from "../session/commit.ts"; import { SessionInvariantError, SessionPendingAssistantMessageError } from "../session/session.ts"; import type { @@ -58,15 +60,7 @@ import { setValue, } from "../session/values.ts"; import { formatSkillInvocation } from "../skills.ts"; -import { - type ActiveDrive, - type Config, - type DriveScope, - type LaneCommand, - type LaneDriveCapabilities, - type LaneState, - SliceNotImplemented, -} from "./types.ts"; +import { type Config, type Drive, type LaneCommand, type LaneState, SliceNotImplemented } from "./types.ts"; type EmitBatch = (events: readonly HarnessEvent[], context: Context) => Promise; type WatchHandler = (snapshot: T, filter: (event: HarnessEvent) => boolean, context: Context) => WatchHandle; @@ -108,7 +102,7 @@ function capturedModel(operation: Operation): ModelIdentity | undefined { } /** Runtime2 implementation of one configured lane. */ -export class Lane implements AgentLane, LaneDriveCapabilities { +export class Lane implements AgentLane { readonly name: string; readonly sessionTree: SessionTree; readonly session: Session; @@ -119,7 +113,8 @@ export class Lane implements AgentLane, Lan private readonly onFault: FaultHandler; private readonly installWatch: WatchHandler; private readonly config: () => Config; - private activeDrive: ActiveDrive | undefined; + /** Package-internal drive owner. Public only because deterministic procedure tests install exact owners directly. */ + activeDrive: Drive | undefined; state: LaneState; closedError: Error | undefined; @@ -171,8 +166,8 @@ export class Lane implements AgentLane, Lan return this.config(); } - ownsDrive(scope: DriveScope): boolean { - return this.activeDrive === scope.owner; + isDriveActive(drive: Drive): boolean { + return this.activeDrive === drive; } mismatch(expected: string, currentOperationId: string | null, last: LaneLastResult | undefined): OperationMismatch { diff --git a/packages/agent/src/harness/runtime2/progress.ts b/packages/agent/src/harness/runtime2/progress.ts index 0c2e9d78e..72671d542 100644 --- a/packages/agent/src/harness/runtime2/progress.ts +++ b/packages/agent/src/harness/runtime2/progress.ts @@ -12,7 +12,8 @@ import { pendingToolOutput, setValue, } from "../session/values.ts"; -import type { DriveScope } from "./types.ts"; +import type { Lane } from "./lane.ts"; +import type { Drive } from "./types.ts"; export interface ProgressChannel { write(item: T): void; @@ -22,7 +23,8 @@ export interface ProgressChannel { } function openProgress( - scope: DriveScope, + lane: Lane, + drive: Drive, commitWrite: (item: T) => Write, clear: () => Write, stillOwns: (reader: SessionReader) => Promise, @@ -32,7 +34,7 @@ function openProgress( return { write(item) { if (sealed) return; - const write = scope.lane.command(async (projection, reader) => { + const write = lane.command(async (projection, reader) => { if (!(await stillOwns(reader))) return { kind: "return", result: undefined }; return { kind: "commit", @@ -40,7 +42,7 @@ function openProgress( next: projection, materialize: () => undefined, }; - }, scope.context); + }, drive.context); latest = write; void write.catch(() => {}); }, @@ -55,36 +57,39 @@ function openProgress( } async function readOwnedRun( - scope: DriveScope, + lane: Lane, + drive: Drive, reader: SessionReader, ): Promise { - const durableLane = await reader.getValue(laneStateValue(scope.lane.name), scope.context); + const durableLane = await reader.getValue(laneStateValue(lane.name), drive.context); if (durableLane === undefined) { - throw new SessionInvariantError(`Lane ${JSON.stringify(scope.lane.name)} has no lane state`); + throw new SessionInvariantError(`Lane ${JSON.stringify(lane.name)} has no lane state`); } - if (durableLane.value.currentOperationId !== scope.operationId) return undefined; - const durableState = await reader.getValue(operationStateValue(scope.operationId), scope.context); + if (durableLane.value.currentOperationId !== drive.operationId) return undefined; + const durableState = await reader.getValue(operationStateValue(drive.operationId), drive.context); if (durableState === undefined) { - throw new SessionInvariantError(`Operation ${scope.operationId} has no operation state`); + throw new SessionInvariantError(`Operation ${drive.operationId} has no operation state`); } if (durableState.value.kind !== "run") { - throw new SessionInvariantError(`Operation ${scope.operationId} is ${durableState.value.kind}, expected run`); + throw new SessionInvariantError(`Operation ${drive.operationId} is ${durableState.value.kind}, expected run`); } - if (!scope.lane.ownsDrive(scope)) return undefined; + if (!lane.isDriveActive(drive)) return undefined; return durableState.value; } export function openFrameProgress( - scope: DriveScope, + lane: Lane, + drive: Drive, responseEntryId: string, ): ProgressChannel { - const address = pendingAssistantFrames(scope.operationId, responseEntryId); + const address = pendingAssistantFrames(drive.operationId, responseEntryId); return openProgress( - scope, + lane, + drive, (frame) => appendList(address, frame), () => deleteList(address), async (reader) => { - const state = await readOwnedRun(scope, reader); + const state = await readOwnedRun(lane, drive, reader); if (state === undefined) return false; if (state.phase.kind === "assistant") { const generation = state.phase.generation; @@ -100,16 +105,18 @@ export function openFrameProgress( } export function openToolProgress( - scope: DriveScope, + lane: Lane, + drive: Drive, invocationId: string, ): ProgressChannel> { - const address = pendingToolOutput(scope.operationId, invocationId); + const address = pendingToolOutput(drive.operationId, invocationId); return openProgress( - scope, + lane, + drive, (snapshot) => setValue(address, snapshot), () => deleteValue(address), async (reader) => { - const state = await readOwnedRun(scope, reader); + const state = await readOwnedRun(lane, drive, reader); if (state?.phase.kind !== "tools") return false; return state.phase.batch.calls.some( (call) => call.resultEntryId === invocationId && call.status === "effect_pending", diff --git a/packages/agent/src/harness/runtime2/types.ts b/packages/agent/src/harness/runtime2/types.ts index 10c343bf0..7fcd35faa 100644 --- a/packages/agent/src/harness/runtime2/types.ts +++ b/packages/agent/src/harness/runtime2/types.ts @@ -1,26 +1,17 @@ -import type { Models, RetryPolicy } from "@earendil-works/pi-ai"; +import type { RetryPolicy } from "@earendil-works/pi-ai"; import type { QueueMode } from "../../types.ts"; import type { AgentHarnessOptions, + DriveOptions, DriveOutcome, HarnessEvent, - OperationMismatch, Resources, TerminalOperationOutcome, } from "../agent-harness.ts"; import type { CompactionSettings } from "../compaction/compaction.ts"; -import type { Context } from "../context.ts"; +import { type Context, withoutAbortSignal } from "../context.ts"; import { createGate, type Gate, type GateControl } from "../execution/effect-gate.ts"; -import type { HookRegistry } from "../hooks.ts"; -import type { - CommitResult, - LaneConfiguration, - LaneLastResult, - Operation, - Session, - SessionReader, - Write, -} from "../session/types.ts"; +import type { CommitResult, LaneConfiguration, LaneLastResult, Operation, Write } from "../session/types.ts"; import type { AgentHarnessStreamOptions, AgentHarnessTool } from "../types.ts"; export class SliceNotImplemented extends Error { @@ -46,15 +37,6 @@ export interface Config { readonly entryProjectors: Readonly["entryProjectors"]>>; } -/** Harness-global settings captured by an accepted run. */ -export interface AcceptanceConfig { - readonly resources: Resources; - readonly compaction: CompactionSettings; - readonly steeringMode: QueueMode; - readonly followUpMode: QueueMode; - readonly toolExecution: "sequential" | "parallel"; -} - /** The current durable state owned by one lane. */ export interface LaneState { readonly leafId: string | null; @@ -78,53 +60,65 @@ export type LaneCommand = | { kind: "return"; result: TResult } | { kind: "reject"; error: Error }; -export type CompletionResult = { kind: "ok"; outcome: DriveOutcome } | { kind: "error"; error: unknown }; - -export interface DriveSupervisor { - settleCompletion(result: CompletionResult): void; - retainOwner(): void; - removeOwner(): void; -} - -/** Process-local ownership record for one drive pass. */ -export interface ActiveDrive { +/** One installed process-local drive pass. */ +export class Drive { readonly operationId: string; readonly completion: Promise; readonly gate: Gate; - readonly control: GateControl; - finalizedOutcome?: DriveOutcome; - supervisor?: DriveSupervisor; -} - -export interface ActiveDriveHandle { - readonly active: ActiveDrive; - settle(outcome: DriveOutcome): void; - fail(error: unknown): void; -} - -export function createActiveDrive(operationId: string): ActiveDriveHandle { - let settle!: (outcome: DriveOutcome) => void; - let fail!: (error: unknown) => void; - const completion = new Promise((resolve, reject) => { - settle = resolve; - fail = reject; - }); - void completion.catch(() => {}); - const { gate, control } = createGate(); - return { active: { operationId, completion, gate, control }, settle, fail }; -} - -export interface PassPolicy { - waitForRetry: boolean; - deferredPermits: number; -} - -export interface DriveScope { - readonly lane: LaneDriveCapabilities; - readonly operationId: string; - readonly owner: ActiveDrive; readonly context: Context; - readonly pass: PassPolicy; + readonly installerSignal: AbortSignal | undefined; + readonly waitForRetry: boolean; + deferredPermits: number; + finalizedOutcome: DriveOutcome | undefined; + + private readonly control: GateControl; + private readonly resolveCompletion: (outcome: DriveOutcome) => void; + private readonly rejectCompletion: (error: unknown) => void; + private settled = false; + + 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; + let resolveCompletion!: (outcome: DriveOutcome) => void; + let rejectCompletion!: (error: unknown) => void; + this.completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + this.resolveCompletion = resolveCompletion; + this.rejectCompletion = rejectCompletion; + void this.completion.catch(() => {}); + const { gate, control } = createGate(); + this.gate = gate; + this.control = control; + } + + settle(outcome: DriveOutcome): void { + if (this.settled) return; + this.settled = true; + this.resolveCompletion(outcome); + } + + fail(error: unknown): void { + if (this.settled) return; + this.settled = true; + this.rejectCompletion(error); + } + + beginAbort(cancellation: Promise): void { + this.control.beginAbort(cancellation); + } + + signalAbort(): void { + this.control.signalAbort(); + } + + closeGate(error: Error): void { + this.control.close(error); + } } export type StepResult = @@ -133,19 +127,3 @@ export type StepResult = | { kind: "waiting"; outcome: DriveOutcome } | { kind: "settled"; outcome: TerminalOperationOutcome } | { kind: "lost_ownership" }; - -/** Package-internal capability surface available to direct drive procedures. */ -export interface LaneDriveCapabilities { - readonly name: string; - readonly session: Session; - readonly models: Models; - readonly hooks: HookRegistry; - command( - plan: (projection: LaneState, reader: SessionReader) => LaneCommand | Promise>, - context: Context, - ): Promise; - emitBatch(events: readonly HarnessEvent[], context: Context): Promise; - readConfig(): Config; - ownsDrive(scope: DriveScope): boolean; - mismatch(expected: string, currentOperationId: string | null, last: LaneLastResult | undefined): OperationMismatch; -} diff --git a/packages/agent/test/harness/runtime2/progress.test.ts b/packages/agent/test/harness/runtime2/progress.test.ts index 21487e510..ce78e800f 100644 --- a/packages/agent/test/harness/runtime2/progress.test.ts +++ b/packages/agent/test/harness/runtime2/progress.test.ts @@ -1,24 +1,16 @@ import { type AssistantMessageFrame, createModels } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import { OperationMismatch } from "../../../src/harness/agent-harness.ts"; +import type { WatchHandle } from "../../../src/harness/agent-harness.ts"; import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts"; -import { BACKGROUND_CONTEXT, type Context } from "../../../src/harness/context.ts"; +import { BACKGROUND_CONTEXT, type Context, withAbortSignal } from "../../../src/harness/context.ts"; import { HookRegistry } from "../../../src/harness/hooks.ts"; +import { Lane } from "../../../src/harness/runtime2/lane.ts"; import { openFrameProgress, openToolProgress } from "../../../src/harness/runtime2/progress.ts"; -import { - type ActiveDrive, - type Config, - createActiveDrive, - type DriveScope, - type LaneCommand, - type LaneDriveCapabilities, - type LaneState as RuntimeLaneState, -} from "../../../src/harness/runtime2/types.ts"; +import { type Config, Drive, type LaneState as RuntimeLaneState } from "../../../src/harness/runtime2/types.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; -import type { LaneLastResult, RunState, Session, SessionReader, Write } from "../../../src/harness/session/types.ts"; +import type { RunState, Session, Write } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; -import { createDriveScope } from "./test-utils.ts"; const sessions: Session[] = []; const operationId = "operation"; @@ -39,94 +31,23 @@ class FailingStorage extends MemoryStorage { } } -class TestDriveLane implements LaneDriveCapabilities { - readonly name = "main"; - readonly models = createModels(); - readonly hooks = new HookRegistry(() => {}); - readonly session: Session; - private projection: RuntimeLaneState; - active: ActiveDrive | undefined; +const runtimeConfig: Config = { + tools: [], + resources: {}, + streamOptions: {}, + retryPolicy: { enabled: true, maxRetries: 3, baseDelayMs: 1_000 }, + compaction: DEFAULT_COMPACTION_SETTINGS, + steeringMode: "all", + followUpMode: "all", + toolExecution: "parallel", + toolContext: undefined, + systemPrompt: undefined, + toProviderMessages: () => [], + entryProjectors: {}, +}; - constructor(session: Session, state: RunState) { - this.session = session; - this.projection = { - leafId: null, - configuration, - pendingNextRun: [], - operation: { - meta: { - operationId, - lane: this.name, - sourceLeafId: null, - startedAt: 1, - intent: { kind: "run", promptEntryIds: [] }, - }, - state, - }, - }; - } - - command( - plan: ( - projection: RuntimeLaneState, - reader: SessionReader, - ) => LaneCommand | Promise>, - context: Context, - ): Promise { - return this.session.mutate( - this.name, - async (mutator) => { - const decision = await plan(this.projection, mutator); - switch (decision.kind) { - case "return": - return decision.result; - case "reject": - throw decision.error; - case "commit": { - const commit = await mutator.commit(decision.writes, context); - this.projection = decision.next; - return decision.materialize(commit); - } - } - }, - context, - ); - } - - emitBatch(): Promise { - return Promise.resolve(); - } - - readConfig(): Config { - return { - tools: [], - resources: {}, - streamOptions: {}, - retryPolicy: { enabled: true, maxRetries: 3, baseDelayMs: 1_000 }, - compaction: DEFAULT_COMPACTION_SETTINGS, - steeringMode: "all", - followUpMode: "all", - toolExecution: "parallel", - toolContext: undefined, - systemPrompt: undefined, - toProviderMessages: () => [], - entryProjectors: {}, - }; - } - - ownsDrive(scope: DriveScope): boolean { - return this.active === scope.owner; - } - - 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: "mismatch", - }); - } +function unusedWatch(): WatchHandle { + throw new Error("watch is not used by progress tests"); } function runState(phase: RunState["phase"]): RunState { @@ -148,17 +69,36 @@ function runState(phase: RunState["phase"]): RunState { async function createFixture( state: RunState, storage = new FailingStorage(), -): Promise<{ - lane: TestDriveLane; - scope: DriveScope; - storage: FailingStorage; -}> { +): Promise<{ lane: Lane; drive: Drive; storage: FailingStorage }> { const session = new StorageBackedSession( { id: `progress-${sessions.length}`, createdAt: 1, storageVersion: 1 }, storage, ); sessions.push(session); - const lane = new TestDriveLane(session, state); + const meta = { + operationId, + lane: "main", + sourceLeafId: null, + startedAt: 1, + intent: { kind: "run" as const, promptEntryIds: [] }, + }; + const projection: RuntimeLaneState = { + leafId: null, + configuration, + pendingNextRun: [], + operation: { meta, state }, + }; + const lane = new Lane( + "main", + session, + createModels(), + new HookRegistry(() => {}), + projection, + (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + () => Promise.resolve(), + unusedWatch, + () => runtimeConfig, + ); await session.mutate( "main", (mutator) => @@ -170,22 +110,16 @@ async function createFixture( currentOperationId: operationId, pendingNextRun: [], }), - storedValues.setValue(storedValues.operationMeta(operationId), { - operationId, - lane: "main", - sourceLeafId: null, - startedAt: 1, - intent: { kind: "run", promptEntryIds: [] }, - }), + storedValues.setValue(storedValues.operationMeta(operationId), meta), storedValues.setValue(storedValues.operationState(operationId), state), ], BACKGROUND_CONTEXT, ), BACKGROUND_CONTEXT, ); - const { scope, handle } = createDriveScope(lane, { operationId }); - lane.active = handle.active; - return { lane, scope, storage }; + const drive = new Drive({ operationId }, BACKGROUND_CONTEXT); + lane.activeDrive = drive; + return { lane, drive, storage }; } afterEach(async () => { @@ -194,23 +128,43 @@ afterEach(async () => { describe("runtime2 active drive ownership", () => { it("settles or rejects the shared completion without exposing owner controls through the gate", async () => { - const settled = createActiveDrive(operationId); + const settled = new Drive({ operationId }, BACKGROUND_CONTEXT); const outcome = { kind: "waiting", operationId, reason: "retry", notBefore: 10 } as const; settled.settle(outcome); - await expect(settled.active.completion).resolves.toBe(outcome); + settled.fail(new Error("late failure")); + await expect(settled.completion).resolves.toBe(outcome); - const failed = createActiveDrive(operationId); + const failed = new Drive({ operationId }, BACKGROUND_CONTEXT); const error = new Error("drive failed"); failed.fail(error); - await expect(failed.active.completion).rejects.toBe(error); - expect("beginAbort" in failed.active.gate).toBe(false); + await expect(failed.completion).rejects.toBe(error); + expect("beginAbort" in failed.gate).toBe(false); + }); + + it("owns installer cancellation, pass context, policy, and gate control", () => { + const controller = new AbortController(); + const drive = new Drive( + { operationId, waitForRetry: true, pollDeferred: true }, + withAbortSignal(controller.signal, BACKGROUND_CONTEXT), + ); + + 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); + + const closed = new Error("closed"); + drive.closeGate(closed); + expect(drive.gate.signal.aborted).toBe(true); + expect(() => drive.gate.admit(() => undefined)).toThrow(closed); }); }); describe("runtime2 progress channels", () => { it("enqueues assistant frames in order, seals admission, and exposes the settlement delete", async () => { const responseEntryId = "response"; - const { scope } = await createFixture( + const { lane, drive } = await createFixture( runState({ kind: "assistant", generation: { @@ -231,7 +185,7 @@ describe("runtime2 progress channels", () => { }, }), ); - const progress = openFrameProgress(scope, responseEntryId); + const progress = openFrameProgress(lane, drive, responseEntryId); const frames: AssistantMessageFrame[] = [ { type: "text_delta", contentIndex: 0, delta: "a" }, { type: "text_delta", contentIndex: 0, delta: "b" }, @@ -245,7 +199,7 @@ describe("runtime2 progress channels", () => { expect( ( - await scope.lane.session.readList( + await lane.session.readList( storedValues.pendingAssistantFrames(operationId, responseEntryId), undefined, BACKGROUND_CONTEXT, @@ -259,7 +213,7 @@ describe("runtime2 progress channels", () => { it("replaces tool checkpoints and fences writes after owner identity changes", async () => { const invocationId = "result"; - const { lane, scope } = await createFixture( + const { lane, drive } = await createFixture( runState({ kind: "tools", batch: { @@ -270,35 +224,27 @@ describe("runtime2 progress channels", () => { }, }), ); - const progress = openToolProgress(scope, invocationId); + const progress = openToolProgress(lane, drive, invocationId); progress.write({ content: [{ type: "text", text: "first" }], details: {} }); progress.write({ content: [{ type: "text", text: "second" }], details: {} }); await progress.drain(); expect( - ( - await scope.lane.session.getValue( - storedValues.pendingToolOutput(operationId, invocationId), - BACKGROUND_CONTEXT, - ) - )?.value.content, + (await lane.session.getValue(storedValues.pendingToolOutput(operationId, invocationId), BACKGROUND_CONTEXT)) + ?.value.content, ).toEqual([{ type: "text", text: "second" }]); - lane.active = createActiveDrive(operationId).active; + lane.activeDrive = new Drive({ operationId }, BACKGROUND_CONTEXT); progress.write({ content: [{ type: "text", text: "stale" }], details: {} }); await progress.drain(); expect( - ( - await scope.lane.session.getValue( - storedValues.pendingToolOutput(operationId, invocationId), - BACKGROUND_CONTEXT, - ) - )?.value.content, + (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 { scope, storage } = await createFixture( + const { lane, drive, storage } = await createFixture( runState({ kind: "assistant", generation: { @@ -321,7 +267,7 @@ describe("runtime2 progress channels", () => { ); const failure = new Error("frame commit failed"); storage.failure = failure; - const progress = openFrameProgress(scope, responseEntryId); + const progress = openFrameProgress(lane, drive, responseEntryId); progress.write({ type: "text_delta", contentIndex: 0, delta: "lost" }); diff --git a/packages/agent/test/harness/runtime2/test-utils.ts b/packages/agent/test/harness/runtime2/test-utils.ts index 6b55f6b95..b1e4883d3 100644 --- a/packages/agent/test/harness/runtime2/test-utils.ts +++ b/packages/agent/test/harness/runtime2/test-utils.ts @@ -1,11 +1,4 @@ -import type { DriveOptions } from "../../../src/harness/agent-harness.ts"; -import { BACKGROUND_CONTEXT, type Context, withoutAbortSignal } from "../../../src/harness/context.ts"; -import { - type ActiveDriveHandle, - createActiveDrive, - type DriveScope, - type LaneDriveCapabilities, -} from "../../../src/harness/runtime2/types.ts"; +import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import type { CommitResult, Write } from "../../../src/harness/session/types.ts"; @@ -30,35 +23,6 @@ export class ControlledMemoryStorage extends MemoryStorage { } } -export function createDriveScope( - lane: LaneDriveCapabilities, - options: DriveOptions, - context: Context = BACKGROUND_CONTEXT, -): { scope: DriveScope; handle: ActiveDriveHandle } { - const handle = createActiveDrive(options.operationId); - return { - handle, - scope: { - lane, - operationId: options.operationId, - owner: handle.active, - context: withoutAbortSignal(context), - pass: { - waitForRetry: options.waitForRetry ?? false, - deferredPermits: options.pollDeferred === true ? 1 : 0, - }, - }, - }; -} - -export function createDriveFixture( - lane: LaneDriveCapabilities, - operationId = "operation", - context: Context = BACKGROUND_CONTEXT, -): { scope: DriveScope; handle: ActiveDriveHandle } { - return createDriveScope(lane, { operationId }, context); -} - export function deferred(): { promise: Promise; resolve(): void } { let resolvePromise: (() => void) | undefined; const promise = new Promise((resolve) => { diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index a94ab806c..a90d6d279 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -475,10 +475,16 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", expectTypeOf().toEqualTypeOf<"settled" | "waiting">(); expectTypeOf().returns.toEqualTypeOf>(); expectTypeOf< - Extract + Extract< + keyof AgentLane, + "session" | "models" | "hooks" | "activeDrive" | "command" | "readConfig" | "isDriveActive" | "mismatch" + > >().toEqualTypeOf(); expectTypeOf< - Extract + Extract< + keyof AgentHarness, + "session" | "models" | "activeDrive" | "command" | "readConfig" | "isDriveActive" | "mismatch" + > >().toEqualTypeOf(); expectTypeOf().toEqualTypeOf< "prompt" | "skill" | "prompt_template" | "compaction" | "navigation"