diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index 57f804576..b396fb728 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -895,7 +895,7 @@ interface LaneConfiguration { - A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry). - `LaneConfiguration` is **total**. A setter replaces the whole value; it is never a patch and never a tree entry. -- `Session.createLane(name, at, configuration, context)` is the primitive that creates a lane. It internally opens a mutation on the new lane, validates the permanent lane name, rejects an existing lane or unknown non-null anchor, and consumes that mutator's sole commit to write all three values atomically. The supplied configuration is a total seed; it is not inferred from the anchor or another lane. The method returns the new lane's `SessionTree` view. +- `Session.createLane(name, at, configuration, onCommitted, context)` is the primitive that creates a lane. It internally opens a mutation on the new lane, validates the permanent lane name, rejects an existing lane or unknown non-null anchor, and consumes that mutator's sole commit to write all three values atomically. The supplied configuration is a total seed; it is not inferred from the anchor or another lane. After commit it synchronously invokes optional `onCommitted(context)` in that same continuation, releases the line, awaits the callback's retained promise, and returns the new lane's `SessionTree` view. Event-free callers pass `undefined`. A synchronous callback throw or retained-promise rejection occurs after durable creation: the lane remains and `createLane` rejects. - Creating a lane copies no tree content, no history, and no configuration from its anchor: ``` @@ -904,7 +904,7 @@ TX[ upsert pi.lane.config/{name} = , upsert pi.lane.state/{name} = { currentOperationId: null, pendingNextRun: [] } ] ``` -- The validation and three-value write above are one package-internal procedure over a `SessionMutator`. Event-free `Session.createLane(name, at, configuration, context)` opens the new lane's mutation and invokes that procedure; session-level callers must not wrap this primitive in another mutation. `AgentHarness.createLane(name, at, context)` instead opens the mutation itself, invokes the same procedure with the harness's immutable captured seed, then publishes the new in-memory lane and enqueues `lane_created` before releasing the line (§5.2). +- Session owns the validation and three-value write. Event-free callers pass no committed-publication callback and must not wrap `createLane` in another mutation. `AgentHarness.createLane(name, at, context)` supplies its immutable captured seed and an `onCommitted` callback that publishes the new in-memory lane and calls `emitBatch(lane_created, context)` before the creation mutation returns (§5.2). - Lanes are never deleted or renamed. Names are permanent application keys. - `main` exists in every session. - Two lanes at the same leaf simply diverge on their next append. @@ -1072,6 +1072,7 @@ interface Session context: Context): Promise; createLane(name: string, at: string | null, configuration: LaneConfiguration, + onCommitted: ((context: Context) => void | Promise) | undefined, context: Context): Promise; close(context: Context): Promise; @@ -1080,7 +1081,7 @@ interface Session `Session.mutate` is the only raw session write surface. It queues the complete callback on the named lane's mutation line and supplies a lane-bound `SessionMutator` plus the same invocation Context. The mutator is valid only until that callback returns or throws. Reads do not consume it. Its first `commit()` attempt consumes its commit capability before awaiting storage, so a second attempt rejects even when the first failed. The callback may return without committing. Every read and the sole commit receive that invocation Context unless the callback deliberately derives a child. It must not retain the mutator or Context as a receiver default, invoke another mutating `Session`/`SessionTree` method, wait for lane idleness, or perform provider, tool, hook, or timer effects; those operations either reacquire the same line or hold it across work that must remain concurrent. Separate durable transitions use separate `mutate` calls. -The high-level mutating methods on `SessionTree` and `Session` — value setters, appends, and `createLane` — acquire the appropriate mutation line themselves and consume one private mutator commit. Callers invoke them directly rather than wrapping them in `mutate`. Reads remain available directly on `Session`, `SessionTree`, and `SessionMutator`. +The high-level mutating methods on `SessionTree` and `Session` — value setters, appends, and `createLane` — acquire the appropriate mutation line themselves and consume one private mutator commit. Callers invoke them directly rather than wrapping them in `mutate`. `createLane` alone accepts an optional synchronous committed-publication callback: it invokes the callback after commit in the same continuation, retains any returned promise without awaiting it on the line, then awaits it after the mutation returns. A callback throw or retained-promise rejection means the lane committed but process-local publication failed. Reads remain available directly on `Session`, `SessionTree`, and `SessionMutator`. Declaration-merged custom `AgentMessage` variants are trusted in-process types like other internal values; extensions that violate them are defective. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. Every additional lane is created through `Session.createLane()` with a total configuration. @@ -1532,7 +1533,7 @@ A declined summarized navigation moves nothing: the leaf stays at the source, an ## 3.6 Acceptance -`accept(request, context)` is the only operation-acceptance primitive. It performs state-independent normalization outside the lane line, then commits exactly one acceptance transaction through `Lane.command(plan, context)`. The command checks the current operation, captures current `pendingNextRun`, and validates every durable state-dependent input. It does not consult process-local model/tool registries. It passes the invocation Context through `Session.mutate`, every read/commit, fault handling, synchronous post-commit event enqueue, and later delivery; Context is never added to operation metadata or the write set. After commit, it publishes the owned projection and enqueues the complete acceptance event batch before releasing the lane line, then awaits delivery outside the line. It invokes no hook, provider, tool, timer, process reservation, or process owner. It returns `OperationAdmission` after commit and event delivery; only a later `drive({ operationId }, context)` may own asynchronous work. +`accept(request, context)` is the only operation-acceptance primitive. It performs state-independent normalization outside the lane line, then commits exactly one acceptance transaction through `Lane.command(plan, context)`. The command checks the current operation, captures current `pendingNextRun`, and validates every durable state-dependent input. It does not consult process-local model/tool registries. It passes the invocation Context through `Session.mutate`, every read/commit, fault handling, synchronous post-commit `emitBatch`, and later delivery; Context is never added to operation metadata or the write set. After commit, it publishes the owned projection and calls `emitBatch` as the mutation callback's final action, then awaits the retained delivery promise after `Session.mutate` returns. It invokes no hook, provider, tool, timer, process reservation, or process owner. It returns `OperationAdmission` after commit and event delivery; only a later `drive({ operationId }, context)` may own asynchronous work. | From | Request | Transaction | |---|---|---| @@ -2782,7 +2783,7 @@ There is no harness-level telemetry default. The same shared harness may serve c `create(options, context)` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before projection restore, it commits that seed as the first `pi.lane.config` for a fresh or normalized-v3 `main`, using the create invocation Context for every read and the seed commit. Existing lanes use only their current config; the seed never overrides them. A configuration-less lane in a format-4 session is corrupt. -`createLane(name, at, context)` enters the new lane's mutation line and invokes the same package-internal validation/write procedure as `Session.createLane(name, at, capturedSeed, context)`, regardless of later changes to existing lanes. After commit, while still on that line, it publishes the new `Lane` in the harness and enqueues `lane_created` with the same invocation Context; listener delivery is awaited only after line release. It maps session validation failures to `LaneExists`, `InvalidLane`, or `UnknownTarget`. `Session.createLane` itself remains the event-free Session primitive. Lane configuration and metadata setters likewise include their events in the committing lane job; setters replace only their lane's bound value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... }, context)` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. +`createLane(name, at, context)` calls `Session.createLane(name, at, capturedSeed, onCommitted, context)`, regardless of later changes to existing lanes. Session owns validation and the durable transaction. Its synchronous committed-publication callback seals the preconstructed `Lane` if close or fault won, publishes it in the harness, and calls `emitBatch(lane_created, context)` in the exact commit-observation continuation. Session releases the line before awaiting the callback's retained delivery promise. Harness maps session validation failures to `LaneExists`, `InvalidLane`, or `UnknownTarget`; event-free Session callers pass `undefined`. If committed process-local publication throws or its retained promise rejects, the durable lane remains, Harness faults, and later public calls reject that fault. Lane configuration and metadata setters likewise include their events in the committing lane job; setters replace only their lane's bound value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... }, context)` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. Initial, replacement, and hook-patched stream options are trusted typed internal values. Patch deletion semantics are applied before publication; extensions that return values outside the declared types are defective rather than runtime-validated by the harness. @@ -2864,23 +2865,26 @@ The bounded reads are: `SessionReader` exposes branch scanning so the capture uses only the callback-scoped read capability. Required snapshot references must exist at the expected pending/entry kind and message role. For tools, `sourceIndex` is the zero-based index in the assistant message's complete content array and must name a tool-call block. Missing or contradictory required data faults the harness and unsubscribes the incomplete watcher. An absent frame list or tool checkpoint is valid and omits that optional partial. -The watcher registration and live-state clone are synchronous. A provider/tool update after registration publishes its new live presentation state and enqueues its event without an intervening await, so the watcher receives that update after its older clone. Frame/checkpoint commits are lane jobs and queue behind the capture; the durable reads therefore see a prefix no newer than the boundary. +The watcher registration and live-state clone are synchronous. A provider/tool update after registration publishes its new live presentation state and calls `emitBatch` without an intervening await, so the watcher receives that update after its older clone. Frame/checkpoint commits are lane jobs and queue behind the capture; the durable reads therefore see a prefix no newer than the boundary. -Every event-producing committing harness lane job performs this order before releasing the lane line: +Every event-producing committing harness lane job performs this order in the exact continuation that observes successful commit: ```text commit succeeds → publish the small owned lane projection -→ synchronously enqueue the complete event batch with its emitting Context -→ release the lane line -→ deliver listeners asynchronously outside the lane line +→ synchronously call emitBatch with the complete batch and emitting Context + - clone payloads + - bind current recipients + - append one delivery to the global tail +→ return from the mutation callback without awaiting delivery +→ after Session.mutate returns, await the retained delivery promise ``` -Event enqueue binds the current recipient set immediately. Delivery never consults listeners that registered later. Event handlers remain outside the lane/storage line so they cannot block commits or deadlock through reentrant lane mutation. +`emitBatch` binds the current recipient set immediately. Delivery never consults listeners that registered later. Listener execution may begin after publication and binding but before the lane line technically releases; the mutation never awaits it, and a reentrant lane read queues behind the current job. The public operation resolves only after its direct listeners settle. Therefore the watcher-registration race has only two outcomes: -- publication/enqueue first: the watch snapshot includes the committed change and the watcher receives no old event; +- commit publication and `emitBatch` first: the watch snapshot includes the committed change and the watcher receives no old event; - watcher first: the snapshot precedes the change and the complete later `{ event, context }` batch is buffered. There is no gap or duplicate. The Context passed to `watch` or `start` never replaces an event's emitting Context. @@ -2955,9 +2959,9 @@ A lane watcher receives matching lane events plus lane-less events. The harness- ## 5.5 Events -One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot transform the in-flight procedure, payloads are isolated from procedure state and from other listeners, and a throw produces `handler_error` plus telemetry without affecting execution. Listeners run in registration order and delivery awaits each one. Each listener receives `(event, context)`, where Context is the invocation that emitted the event, retained with buffered events and bound to the recipient set at enqueue. A listener may read harness state and perform external observational effects under that Context, but must not call state-mutating harness methods; interception belongs in hooks, and new operations belong to an external caller after listener delivery. This avoids reentrant mutation and event-delivery deadlocks. +One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot transform the in-flight procedure, payloads are isolated from procedure state and from other listeners, and a throw produces `handler_error` plus telemetry without affecting execution. Listeners run in registration order and delivery awaits each one. Each listener receives `(event, context)`, where Context is the invocation that emitted the event, retained with buffered events and bound to the recipient set by `emitBatch`. A listener may read harness state and perform external observational effects under that Context, but must not call state-mutating harness methods; interception belongs in hooks, and new operations belong to an external caller after listener delivery. This avoids reentrant mutation and event-delivery deadlocks. -Durable tree and session-metadata events are enqueued **after** commit while the mutation job still owns the lane — `entry_added` therefore means queryable. This includes direct idle/pending appends through the harness lane facade, lane configuration setters, session-name/entry-label setters, acceptance, and harness lane creation; the last shares a package-internal mutator procedure with event-free `Session.createLane` so publication and enqueue happen before line release. Enqueue publishes the complete batch and binds recipients/Context synchronously; listener delivery happens later outside the line. Multi-write event batches wait for full success, then follow mutation order. Run acceptance publishes `run_start`, then `message_start` → `message_end` → `entry_added` for every placed captured-next-run and request-prompt message in placement order, then `queue_update` when next-run items were captured. Standalone `compaction_start` and `navigation_start` likewise publish after their acceptance transaction and before `accept` resolves. Every acceptance event carries the accepting Context; no post-acceptance work has started yet. Explicit accept+drive and their convenience composition publish the same lifecycle once. Acceptance's message lifecycle is enqueued after its entries commit. Provider/tool lifecycle elsewhere may still precede the transaction that makes its observed content durable. +Durable tree and session-metadata events call `emitBatch` **after** commit in the same commit-observation continuation — `entry_added` therefore means queryable. This includes direct idle/pending appends through the harness lane facade, lane configuration setters, session-name/entry-label setters, acceptance, and harness lane creation; Session invokes the last one's committed-publication callback before its creation mutation returns. `emitBatch` publishes the complete batch and binds recipients/Context synchronously; the mutation does not await delivery, while the public operation does. Multi-write event batches wait for full success, then follow mutation order. Run acceptance publishes `run_start`, then `message_start` → `message_end` → `entry_added` for every placed captured-next-run and request-prompt message in placement order, then `queue_update` when next-run items were captured. Standalone `compaction_start` and `navigation_start` likewise publish after their acceptance transaction and before `accept` resolves. Every acceptance event carries the accepting Context; no post-acceptance work has started yet. Explicit accept+drive and their convenience composition publish the same lifecycle once. Acceptance's message lifecycle is published by `emitBatch` after its entries commit. Provider/tool lifecycle elsewhere may still precede the transaction that makes its observed content durable. ```ts type HarnessEventPayload = @@ -3433,7 +3437,7 @@ Context solves two separate propagation problems. `context.telemetryContext` is Invocation cancellation is not durable cancellation. An aborted `context.abortSignal` must not call `requestAbort()`, write `cancel_requested`, or commit a durable aborted operation result while control remains running. Only explicit `requestAbort(operationId, context)`/`abort(context)` owns that transition. Context objects, signals, telemetry objects, and arbitrary backend-native span objects are never stored durably or serialized as business arguments. RPC carries required trace/cancellation control-plane metadata and reconstructs a fresh local Context; whether selected adapter-managed typed values may also cross remains an RPC design decision. -Shared `AgentHarness`, `AgentLane`, `Session`, and `SessionTree` receivers retain no caller Context and expose no receiver-level telemetry default. Process-local objects representing one invocation, such as a drive pass or event subscription, may retain their derived Context for that invocation only. Buffered events retain `{ event, context }` and bind recipients at enqueue so delayed local handlers and RPC event frames preserve source lineage. +Shared `AgentHarness`, `AgentLane`, `Session`, and `SessionTree` receivers retain no caller Context and expose no receiver-level telemetry default. Process-local objects representing one invocation, such as a drive pass or event subscription, may retain their derived Context for that invocation only. Buffered events retain `{ event, context }`; `emitBatch` binds recipients synchronously so delayed local handlers and RPC event frames preserve source lineage. WP02 preserves this propagation boundary but introduces no new telemetry spans, generic RPC transport, trace-carrier codec, distributed cancellation implementation, or drive-ownership policy. Required trailing Context position is already the resolved receiver contract; adapter normalization of omitted optional business arguments remains separate RPC work. @@ -3565,10 +3569,12 @@ Every package implements its named concern end to end and tests its normal path, | WP01 (complete) | Replaced retained register/custom-state APIs with bound values/lists across Session, Memory, JSONL, SQLite, instrumentation, conformance, and public application access. | WP00 | [Bound values and lists](work-packages/01-bound-values-lists.md) | | WP02 (complete) | Implemented atomic prompt/skill/template acceptance, minimal open-operation attachment, lane-line execution inspection, and gap-free ad-hoc lane watch capture without starting execution. | WP01 | [Atomic acceptance and coherent attachment](work-packages/02-atomic-run-acceptance.md) | | WP03 (complete) | Removed the unused wall-clock drive deadline and non-durable yielded outcome before redesigning execution manual-first. | WP02 | [Remove drive deadlines](work-packages/03-remove-drive-deadlines.md) | +| WP04 (complete) | Replaced caller-operated event delivery gates with synchronous `emitBatch` publication and made Session own committed lane publication. | WP03 | [Mutation publication and event delivery](work-packages/04-mutation-publication.md) | +| WP05 (design) | Add manual-first drive as direct breakpoint-wrapped intent/effect/outcome procedures. | WP04 | [Manual-first drive](work-packages/05-manual-first-drive.md) | The old Types, Session/Memory, JSONL, and SQLite rows described work already present in the repository. Their remaining cross-backend delta was WP01; they are not separate future packages. -The former R1 row is likewise no longer a separate candidate. Its public types, lane/config shell, restore inventory, hook/event primitives, breakpoint/effect gate, neutral assistant/tool blocks, and direct primitive coverage are already present. WP02 took atomic acceptance. WP03 removes deadline/yield policy before the remaining lane-owned drive pass, expected-id fencing, same-id joining, manual transition control, and task lifecycle are reassessed as one manual-first execution package. The detailed R2/R3 rows below preserve requirements but are not an approved handoff. The harness-specific checkpoint update callback shape and its compatibility test remain R4 work. +The former R1 row is likewise no longer a separate candidate. Its public types, lane/config shell, restore inventory, hook/event primitives, breakpoint/effect gate, neutral assistant/tool blocks, and direct primitive coverage are already present. WP02 took atomic acceptance. WP03 removed deadline/yield policy. WP04 first simplifies the publication boundary that every drive transition will use; WP05 then reassesses the remaining lane-owned drive pass, expected-id fencing, same-id joining, manual transition control, and task lifecycle as one manual-first execution package. The detailed R2/R3 rows below preserve requirements but are not an approved handoff. The harness-specific checkpoint update callback shape and its compatibility test remain R4 work. ## Future package candidates @@ -3637,7 +3643,7 @@ Operations: 32. Every final or synthetic response settlement — normal, recovery, cancellation, or external finalization — atomically deletes its exact frame list. Idle forks contain no frame lists. A restored partial may appear in `streamingMessage` but never in `transcript` before settlement. 33. The provider loop never awaits storage per frame; frame appends are enqueued synchronously in provider-event order, and awaiting the latest frame-write promise at stream settlement implies every accepted append completed. 34. Successful attachment publishes only complete lane projections and an open-operation inventory. It resolves no model/tool identity and starts no work. A later drive rereads and fences durable state. -35. Every event-producing committing harness lane job publishes its owned projection and synchronously enqueues its complete event batch before releasing the line; this includes direct appends, lane and metadata setters, acceptance, and harness lane creation. Listener delivery remains outside it. A lane watch registers buffering and clones live presentation synchronously, then performs bounded durable reads while holding that line. Snapshot plus buffered events has no gap or duplicate and replays no pre-registration lifecycle. Enqueue binds recipients and the emitting Context immediately; a delayed watcher receives the object-identical source Context, never its start Context. +35. Every event-producing committing harness lane job publishes its owned projection and calls `emitBatch` with its complete event batch in the exact continuation that observes commit, as the callback's final action; this includes direct appends, lane and metadata setters, acceptance, and harness lane creation. The mutation never awaits delivery, but the public operation does. A lane watch registers buffering and clones live presentation synchronously, then performs bounded durable reads while holding the line. Snapshot plus buffered events has no gap or duplicate and replays no pre-registration lifecycle. `emitBatch` binds recipients and the emitting Context immediately; a delayed watcher receives the object-identical source Context, never its start Context. 36. Shared harness/lane/Session/SessionTree receivers retain no invocation Context and expose no receiver-level telemetry default. Concurrent calls preserve independent telemetry and cancellation lineage. Context and its values are neither durable operation data nor serialized business arguments. RPC cancel/disconnect reaches only the matching invocation through `context.abortSignal` and never becomes durable cancellation. 37. Automatic mode never returns `action_required`. Manual mode leaves no public execution promise invisibly parked: it reports the current action, same-operation observers see that action without releasing it, and only `executeAction(context)`/`runToCompletion(context)` advance it. 38. Process-local model/tool registry absence never becomes durable waiting state or an acceptance error. Pre-intent request-configuration absence fails in-band without fabricating a response/usage; missing requested tools stage `isError` tool-result messages with no invented details; uncertain effects settle under their existing recovery rules first. @@ -3672,7 +3678,7 @@ Each durable mutation race has exactly two durable histories. Process-local owne | `requestAbort` vs parked ordinary breakpoint | cancellation rejects the interruptible barrier with `AbortRequested`; the ordinary statement after it does not run, and reconciliation proceeds after the lane mutation | | `close` vs parked manual action | action rejected unexecuted; durable state is the committed prefix | | attachment vs concurrent resume | attachment owns the session before publication; after return, resume rereads durable state and stale `open` remains harmless | -| watcher registration vs state publication | watcher first → old snapshot plus the complete buffered event batch; publication/enqueue first → new snapshot without that old batch | +| watcher registration vs state publication | watcher first → old snapshot plus the complete buffered event batch; publication/`emitBatch` first → new snapshot without that old batch | | close vs attachment | create completes and publishes a fully open harness; or close/fault rejects attachment without a partial harness | | snapshot capture vs resume | capture first yields pre-resume snapshot plus events; resume publication first yields post-transition snapshot | | external finalization vs attachment/inspection | finalization is visible as idle/latest result; or attachment sees the still-open operation and later lane-line inspection observes publication | @@ -3694,7 +3700,7 @@ One corruption assertion constructs an `aborted` response with running control d **Cross-cutting:** - **Backend conformance.** One suite, three backends, identical results — including checkpoint scalar set/replace/delete, list append/page/whole-key-delete with identical sequence cursors and reduced frame sequences, and torn-transaction handling exposing no list element. Memory/SQLite retain one current checkpoint; JSONL may retain superseded bytes physically but compaction produces identical logical state, including preserved list cursors. Internal values are not cloned or shape-validated. Write-order assertions use the instrumented decorator, never a durable log. -- **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 enqueue, and both registration/publication orders without gaps or duplicates. +- **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.** The same scenario in automatic and manual breakpoint mode must produce byte-identical durable state. Convenience calls and explicit `accept`/`drive`/`requestAbort` compositions must likewise produce byte-identical durable state and equivalent events/results. Manual calls report `action_required`; `executeAction(context)` advances one barrier and `runToCompletion(context)` consumes action branches. - **Breakpoint catalog.** Each runtime slice specifies and tests the stable `ActionInfo.kind` values and JSON-safe details for the boundaries it introduces. A parked operation performs no following procedure work; abort rejects interruptible ordinary barriers into reconciliation but leaves cancellation barriers parked; close/fault rejects every barrier. - **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`. diff --git a/packages/agent/docs/work-packages/02-atomic-run-acceptance.md b/packages/agent/docs/work-packages/02-atomic-run-acceptance.md index 028a15972..85bd9c780 100644 --- a/packages/agent/docs/work-packages/02-atomic-run-acceptance.md +++ b/packages/agent/docs/work-packages/02-atomic-run-acceptance.md @@ -2,7 +2,7 @@ ## Status -Complete. Phase A established minimal attachment, open-operation inventory, lane-line inspection/watch capture, enqueue-time recipient binding, and in-band identity-failure vocabulary. Phase B landed in `beac75ecc` with focused, monorepo-check, full-suite, and final Fable review passing. +Complete. Phase A established minimal attachment, open-operation inventory, lane-line inspection/watch capture, commit-continuation recipient binding, and in-band identity-failure vocabulary. Phase B landed in `beac75ecc` with focused, monorepo-check, full-suite, and final Fable review passing. Implementation also updated downstream protocol/coding-agent wire projections required by the changed public contract. No execution owner, provider/tool effect, timer, retry, polling, cancellation procedure, manual action, or terminal settlement was added. @@ -27,8 +27,8 @@ After `AgentHarness.create(options, context)` succeeds: - attachment starts no hook, provider, tool, timer, breakpoint, drive owner, or application callback; - `inspectExecution(context)` observes the small projection and local owner on the lane line; - `watch(context)` registers buffering, clones live presentation, and performs bounded snapshot reads in one no-write lane job; -- every committing lane job publishes memory and synchronously enqueues its event batch before releasing the line; -- listener delivery remains asynchronous and outside the lane line. +- every committing lane job publishes memory and synchronously binds its event batch in the commit continuation; +- the mutation does not await listener delivery, while the public operation does. WP02 does not implement drive, provider generation, hooks, tools, retries, deferred polling, cancellation procedures, manual action execution, or terminal settlement. @@ -73,21 +73,19 @@ The bounded read set is: `ToolCall.sourceIndex` is the index in the assistant message's full content array, not a filtered tool-call ordinal. A represented call must index a tool-call block. -### 3. Event enqueue, not delivery, belongs on the lane line +### 3. Recipient binding belongs in the commit continuation A successful committing lane job performs: ```text commit → publish small owned projection -→ synchronously bind recipients and enqueue complete `{ event, context }` batch -→ release lane line -→ deliver listeners later outside the line +→ synchronously bind recipients and append the complete `{ event, context }` batch +→ return from the mutation without awaiting delivery +→ await delivery before the public operation resolves ``` -Listeners must never execute on the lane line. - -Recipient binding happens at enqueue. A listener or watcher registered after enqueue cannot receive that historical event even if delivery has not started. +WP02 initially implemented this with `enqueue()` plus caller-operated `start()`. WP04 replaces that gate with one immediate `emitBatch()` call in the same commit-observation continuation. Recipient binding and delivery awaiting remain unchanged. A listener or watcher registered after `emitBatch()` cannot receive that historical event. The only watcher/publication orders are: @@ -95,11 +93,11 @@ The only watcher/publication orders are: watcher first → snapshot-before + complete buffered event batch -publication/enqueue first +publication/`emitBatch` first → snapshot-after + no old event ``` -A live provider/tool presentation update follows the same synchronous publish-plus-enqueue discipline. Frame/checkpoint commits are lane jobs and queue behind watch capture. +A live provider/tool presentation update follows the same synchronous publish-plus-`emitBatch` discipline. Frame/checkpoint commits are lane jobs and queue behind watch capture. ### 4. Inspection is a no-write lane-line observation @@ -144,7 +142,7 @@ A misconfigured convenience prompt eventually returns a durable failed run from ### 7. Invocation Context remains explicit -Every current public harness/lane operation receives trailing `context: Context`. Acceptance, attachment, watch capture, Session reads/commit, faults, and event enqueue preserve it. Shared harness/lane/Session receivers retain no caller Context. Context and its signal/telemetry values are never durable business data. +Every current public harness/lane operation receives trailing `context: Context`. Acceptance, attachment, watch capture, Session reads/commit, faults, and event publication preserve it. Shared harness/lane/Session receivers retain no caller Context. Context and its signal/telemetry values are never durable business data. Buffered events retain the exact emitting Context. Invocation cancellation remains distinct from durable `requestAbort()`. @@ -283,8 +281,8 @@ Inside one lane command: 5. parent captured next-run entries before request prompt entries; 6. commit exactly once; 7. publish the small owned projection; -8. synchronously enqueue the acceptance event batch with the accepting Context; -9. release the lane line; +8. synchronously call `emitBatch` with the acceptance event batch and accepting Context; +9. return from the mutation callback without awaiting delivery; 10. await event delivery before `accept` resolves. Exact writes: @@ -320,7 +318,7 @@ Update `harness.md` before runtime source: - replace predictive status/classifier with open inventory; - specify configured/captured identity inspection without resolution; - move detailed reads to ad-hoc lane-line watch capture; -- require recipient binding and event-batch enqueue before line release; +- require recipient binding in the exact commit-observation continuation; - remove identity preflight/suspension/error/event types; - specify in-band model/tool unavailability and configuration provenance; - require direct detail-free synthetic tool-result messages; @@ -357,16 +355,16 @@ Modify session types: ### Event publication -Modify `HarnessEventBus`: +WP02 initially added synchronous recipient binding with a reserved delivery gate. WP04 supersedes only that mechanism: -- snapshot ordinary and watcher recipients synchronously in `emit()`; -- deliver only to that bound list; -- retain `{ event, context }` in watcher buffers; -- a watcher registered after enqueue receives nothing from that event. +- `HarnessEventBus.emitBatch()` snapshots ordinary and watcher recipients synchronously; +- delivery uses only that bound list; +- watcher buffers retain `{ event, context }`; +- a watcher registered after `emitBatch` receives nothing from that event. -Extend `LaneCommand` commit decisions with a synchronous post-commit event batch. After commit succeeds, `Lane.command` publishes `next`, enqueues the complete batch, and only then releases the mutation callback. It awaits delivery outside `Session.mutate` before resolving the command. Route every existing event-producing commit through this path, including direct idle/pending appends, lane configuration setters, and session-name/entry-label setters. +`LaneCommand` commit decisions retain a synchronous post-commit event batch. After commit succeeds, `Lane.command` publishes `next`, calls `emitBatch` as its final mutation action, and carries the delivery promise outside `Session.mutate` before awaiting it. Every existing event-producing commit uses this path, including direct idle/pending appends, lane configuration setters, and session-name/entry-label setters. -For harness `createLane`, factor the existing Session lane-validation/write logic into one package-internal mutator procedure shared with event-free `Session.createLane`. The harness variant enters the new lane's mutation itself, commits through that procedure, publishes `lanesByName`, and enqueues `lane_created` before returning from the mutation callback; it awaits delivery outside the line. +WP04 also moves harness lane publication into `Session.createLane`'s committed-publication callback. Session commits, Harness publishes `lanesByName` and calls `emitBatch(lane_created)` in that continuation, and Session awaits the retained delivery promise after releasing the line. Do not execute listeners on the line. @@ -436,10 +434,10 @@ Provider/tool/configuration-failure transitions are specified now but implemente ### Event publication and watch -- recipient set binds at enqueue: watcher registered after emit but before delivery receives nothing; +- recipient set binds at `emitBatch`: a watcher registered after publication but before delivery receives nothing; - state/event batch publication happens before lane-line release; - direct append, lane configuration, session-name/entry-label, acceptance, and lane-creation commits all use that publication path; -- `value_update` and `lane_created` bind recipients on the committing line, so listeners registered after enqueue receive neither historical event; +- `value_update` and `lane_created` bind recipients in their commit continuation, so later listeners receive neither historical event; - watcher-first gives snapshot-before plus complete events; - publication-first gives snapshot-after without old events; - live update during awaited capture appears only as a buffered event after the cloned live snapshot; @@ -536,7 +534,7 @@ Stop when: - attachment returns minimal complete projections and open inventory; - inspection is a coherent lane-line no-write observation; - watch captures detailed state ad hoc on the lane line; -- state publication and event enqueue occur before line release while delivery remains outside; +- state publication and `emitBatch` recipient binding occur in the commit continuation while the mutation never awaits delivery; - snapshots and buffered events have no gap or duplicate; - required payload corruption faults its consumer; - no execution effect or owner is introduced; diff --git a/packages/agent/docs/work-packages/04-mutation-publication.md b/packages/agent/docs/work-packages/04-mutation-publication.md new file mode 100644 index 000000000..79cdec6a2 --- /dev/null +++ b/packages/agent/docs/work-packages/04-mutation-publication.md @@ -0,0 +1,335 @@ +# WP04 — Mutation publication and event delivery + +## Status + +Complete. Phase A and the final implementation rereview passed Fable with no findings. Focused agent/server/SQLite tests, `npm run build`, `npm run check`, and `./test.sh` pass. `packages/agent/docs/harness.md` remains normative. + +WP02 established atomic acceptance, recipient binding, and coherent lane watches. WP03 removed drive deadlines. WP04 removes the caller-operated event-delivery gate without weakening those guarantees and makes `Session.createLane()` own lane creation end to end. The manual-first drive package follows as WP05. + +## Problem + +A committing lane job currently uses a two-part event API: + +```text +inside Session.mutate: + commit + publish process-local state + delivery = events.enqueue(batch, context) + +outside Session.mutate: + await delivery.start() +``` + +The split preserves the correct boundary, but it is a footgun: calling `emit()` too early, calling `start()` too early, or dropping `start()` can violate observation semantics or stall the global event tail. `Harness.createLane()` repeats the choreography manually. + +Lane creation has a second one-off boundary. `Session.createLane()` owns validation and the durable transaction, but Harness cannot publish its process-local `Lane` and bind `lane_created` recipients from that same commit continuation. Harness therefore opens `Session.mutate()` itself and calls exported `createLaneWithMutator()`. + +WP04 removes both caller-operated seams while preserving current direct-listener and hook barriers. + +## Required semantics + +### Direct events remain awaited observations + +Direct `events.on()` listeners remain passive but causally ordered: + +```text +hook or preparation +→ commit +→ publish process-local state +→ bind and append event batch +→ release lane mutation line +→ await direct listeners +→ resolve operation +→ later hook or transition +``` + +Passive means a listener cannot transform the in-flight operation and listener failures are isolated as `handler_error`. It does not mean fire-and-forget. An extension may update process-local state in an event listener and inspect it from a later hook. Awaiting also supplies producer backpressure. + +A direct listener must not call a state-mutating harness API: an emitted mutation would queue a later event behind the event currently awaiting that listener. Read-only lane calls remain legal. + +Deliberate exceptions remain unchanged: + +- watchers and RPC/watch consumers use their own buffered FIFO and are not awaited by operations; +- fault publication is fire-and-forget, and close never waits for listener completion; +- high-frequency tool updates enqueue every event and retain only the latest delivery promise; tool settlement awaits that promise before `after_tool` and outcome publication, which drains all earlier updates through global FIFO without per-update backpressure. + +### Commit and recipient binding remain one continuation + +Every event-producing committing lane job performs, in the exact continuation that observes successful commit: + +```text +commit succeeds +→ publish complete process-local state +→ synchronously call emitBatch(batch, context) + - clone payloads + - bind current ordinary listeners and watchers + - append the complete batch to the global delivery tail +→ return from the mutation callback +``` + +There is no scheduler-owned after-release publication phase. Moving recipient binding into a later promise continuation creates a gap in which another task may observe committed state and register a listener before the old event binds recipients. + +`emitBatch()` starts asynchronous delivery immediately and returns its completion promise. The mutation callback never awaits that promise. Listener code may begin after publication/binding but before the mutation line technically releases; a reentrant lane read queues behind the current job. The command carries the promise out of `Session.mutate()` and awaits it before resolving publicly. + +This preserves the two legal watcher races: + +```text +watcher registration first +→ snapshot-before + complete buffered batch + +commit publication first +→ snapshot-after + no old event +``` + +### Ordering + +- One non-empty `emitBatch()` call publishes one contiguous batch. +- Batches enter the existing global event tail in `emitBatch()` invocation order, including across lanes. +- Events within a batch retain source order. +- Direct listeners run serially in registration order. +- Same-lane committing jobs bind batches in lane mutation order. +- A lane procedure awaits each command's delivery before invoking its next hook or transition. +- Hooks on unrelated lanes may overlap; there is no total cross-lane hook order. +- Context remains the exact emitting invocation Context and is always the final parameter. + +## Event bus contract + +Replace the public/internal delivery split with: + +```ts +class HarnessEventBus implements Events { + emit(event: HarnessEvent, context: Context): Promise; + emitBatch(events: readonly HarnessEvent[], context: Context): Promise; +} +``` + +`emitBatch()`: + +1. returns an already-resolved promise for an empty batch or a closed bus; +2. synchronously structured-clones every payload and binds its current recipients; +3. appends one contiguous delivery to the existing global tail; +4. delivers cloned payloads serially with the emitting Context; +5. isolates listener failures and publishes non-recursive `handler_error` as today; +6. returns a promise that resolves after eligible direct listeners settle and never rejects because a listener failed. + +Synchronous publication defects such as an uncloneable internal payload still throw in the caller's commit continuation and follow the existing harness-fault path. + +Delete: + +- `HarnessEventDelivery`; +- `HarnessEventBus.enqueue()`; +- caller-operated `start()`; +- delivery gates and `pendingStarts`; +- close-time forced gate release. + +`close(error)` seals listener/watch registration and future publication immediately. Already appended batches retain their bound recipients and drain through the existing tail; listener completion still does not block Harness close. + +## Lane command integration + +The commit branch of `Lane.command()` returns a plain internal outcome containing the caller result and optional delivery promise: + +```ts +const events = decision.events?.(commit) ?? []; +const delivery = events.length === 0 + ? undefined + : this.onEvent(events, context); + +return { + kind: "return", + result, + ...(delivery === undefined ? {} : { delivery }), +}; +``` + +`onEvent` returns `Promise` and delegates to `emitBatch()`. It is called only after commit, complete process-local state publication, and synchronous result materialization, as the final action before returning the mutation outcome. + +After `Session.mutate()` returns: + +```ts +if (outcome.kind === "reject") throw outcome.error; +await outcome.delivery; +return outcome.result; +``` + +Expected no-commit rejections publish no events. Commit/materialization/publication errors retain the existing harness-fault semantics. + +## Session lane creation contract + +`Session.createLane()` owns validation, commit, and the synchronous committed-publication callback. Context remains last: + +```ts +createLane( + name: string, + at: string | null, + configuration: LaneConfiguration, + onCommitted: ((context: Context) => void | Promise) | undefined, + context: Context, +): Promise; +``` + +The implementation performs: + +```text +enter the prospective lane's mutation line +→ validate name, absence, complete lane shape, and target +→ commit lane configuration + leaf + idle lane state +→ synchronously invoke onCommitted(context) in that same commit continuation +→ retain its returned promise inside a non-thenable outcome object +→ return from the mutation callback and release the line +→ await the retained promise +→ return Session.view(name) +``` + +The callback receives neither `SessionTree` nor `SessionMutator`. It is only the process-local publication point. Its synchronous prefix must complete the publication needed before another same-lane job can run. Ordinary Session callers pass `undefined`. + +If validation or commit fails, the callback is not invoked. If the callback throws after commit or its retained promise rejects after line release, the durable lane remains and the caller rejects; Harness converts that committed-publication defect to its existing fault path. A promise returned by the Harness callback is event delivery, whose listener failures are isolated by the bus. + +The current lane validation/transaction implementation becomes private to Session. Delete exported `createLaneWithMutator()` and its direct tests. + +Harness preconstructs a detached `Lane`, then calls Session: + +```ts +const lane = this.buildLane(name, state); + +await this.session.createLane( + name, + at, + this.seed, + (context) => { + if (this.closedError !== undefined) lane.seal(this.closedError); + this.lanesByName.set(name, lane); + return this.events.emitBatch( + [{ type: "lane_created", lane: name, at }], + context, + ); + }, + context, +); + +return Result.ok(lane); +``` + +The callback's synchronous prefix publishes `lanesByName` and binds `lane_created` before the first creation job releases its line. A queued duplicate therefore cannot report `LaneExists` before the winner is visible through `harness.lane(name)`. + +If close or fault wins while commit is admitted, the callback publishes the new Lane sealed. Emission on the already-closed bus is a resolved no-op, matching the existing admitted-creation race. The successful admitted creation still returns its sealed Lane. + +## Scope + +### Source + +Modify: + +- `packages/agent/src/harness/events.ts`; +- `packages/agent/src/harness/runtime2/lane.ts`; +- `packages/agent/src/harness/runtime2/harness.ts`; +- `packages/agent/src/harness/session/types.ts`; +- local Session implementations and tests required by the `createLane` signature; +- direct event primitive, acceptance, watch, lane, and harness tests. + +Remote/experimental runtime behavior is not a design constraint for WP04. For compile compatibility only, `RemoteSession.createLane` invokes a non-`undefined` callback client-side after its successful create RPC; it does not claim the local commit-continuation boundary. Do not add remote callback transport, protocol machinery, compatibility abstractions, or boundary tests. + +### Documentation + +Update normative `harness.md`: + +- replace enqueue/start language with synchronous `emitBatch` binding and post-mutation awaiting; +- state accurately that listener execution may begin after publication/binding but before technical line release; +- retain awaited direct-listener, event/hook, watcher, Context, close, and ordering semantics; +- replace shared exported mutator-procedure lane creation with `Session.createLane(onCommitted, context)`; +- update invariants, races, tests, glossary, and Part 8; +- link WP04 and move manual-first drive to WP05. + +Update the historical WP02 handoff only where it points forward or claims the old mechanism remains current. Do not rewrite its completed-package record as though WP04 behavior landed in WP02. + +## Non-goals + +- fire-and-forget direct events or a public flush API; +- sequence/watermark delivery redesign; +- per-extension event queues; +- changing hook aggregation or event/hook causal barriers; +- changing watcher/RPC buffering; +- changing tool-update settlement barriers; +- making event-listener mutation safe; +- drive, breakpoints, providers, tools, recovery, retries, polling, abort, or terminal settlement; +- generic Session post-commit/after-release task APIs; +- remote callback execution or remote-runtime redesign. + +## Required tests + +### Event primitives + +- `emitBatch` binds ordinary listeners and watchers synchronously; +- a listener registered after `emitBatch` but before delayed delivery receives nothing; +- a complete batch is contiguous and preserves event order; +- concurrent batch publication preserves invocation order; +- listener payload mutation remains isolated; +- listener rejection emits one non-recursive `handler_error` and does not reject delivery; +- empty and post-close batches resolve without delivery; +- already appended batches drain after close; +- no gate/start tests remain. + +### Lane commands and watch + +- direct listeners may perform reentrant read-only lane inspection without deadlock; +- a command does not resolve until its direct listeners finish; +- committed memory is visible to listeners; +- commit failure publishes nothing; +- a late listener that observes durable state receives no historical event; +- watcher-first yields snapshot-before plus the complete buffered batch; +- publication-first yields snapshot-after without replay; +- source Context identity survives delayed and buffered delivery. + +### Lane creation + +- Session callback runs exactly once after successful commit and never on validation/commit failure; +- callback synchronous publication occurs before a queued duplicate reports `LaneExists`; +- Harness Lane and durable configuration are visible to `lane_created` listeners; +- Harness waits for asynchronous `lane_created` listeners before resolving; +- admitted creation racing close publishes a sealed Lane and does not require event delivery; +- callback throw and retained-promise rejection after commit follow the documented committed-publication failure path; +- ordinary Session creation with `undefined` returns its view; +- no exported `createLaneWithMutator` remains. + +### Ordering barriers + +- acceptance events finish before `accept()` resolves; +- command resolution cannot overtake its awaited direct event delivery; WP05 tests the first subsequent procedure hook; +- event batches are globally ordered across concurrent lane publication; +- tool-update latest-delivery settlement behavior remains unchanged where implemented. + +## Validation + +After documentation: + +```bash +git diff --check -- \ + packages/agent/docs/harness.md \ + packages/agent/docs/work-packages/02-atomic-run-acceptance.md \ + packages/agent/docs/work-packages/04-mutation-publication.md \ + packages/agent/docs/work-packages/05-manual-first-drive.md +``` + +After implementation, run every modified focused test, then: + +```bash +git diff --check +npm run check +./test.sh +``` + +Review the final implementation with Fable before declaring WP04 complete. Do not commit without explicit user approval. + +## Stop condition + +WP04 is complete when: + +- event publication has one `emitBatch()` operation and no caller-operated gate; +- recipient binding remains in the exact commit-observation continuation; +- direct event delivery remains globally FIFO and awaited before public operation resolution; +- existing event/hook causal barriers remain intact; +- lane watches retain exactly the two coherent race outcomes; +- Session owns lane creation and invokes Harness publication before releasing the creation job; +- `createLaneWithMutator()` is gone; +- Context is trailing and source-identical throughout; +- focused tests, `npm run check`, and `./test.sh` pass; +- final Fable review reports no findings. diff --git a/packages/agent/docs/work-packages/05-manual-first-drive.md b/packages/agent/docs/work-packages/05-manual-first-drive.md new file mode 100644 index 000000000..b5bcca59b --- /dev/null +++ b/packages/agent/docs/work-packages/05-manual-first-drive.md @@ -0,0 +1,190 @@ +# WP05 — Manual-first drive + +## Status + +Design draft. Implementation has not started. `harness.md` remains normative. + +WP02 atomic acceptance/observation and WP03 deadline removal are complete. WP04 first simplifies mutation publication and event delivery. WP05 then adds execution without reopening those packages. + +## Mental model + +There are two public phases: + +```text +accept(request) +→ commit one durable starting state +→ return operation id + +drive(operationId) +→ run the operation procedure +→ return terminal, durable wait, or current manual breakpoint +``` + +`accept` never starts execution. `drive` is a thin owner/wrapper around the existing breakpoint mechanism and direct async procedures. It is not a scheduler, action interpreter, plan graph, or second state machine. + +Every repeat-sensitive effect uses the same sandwich: + +```text +prepare intent +→ commit intent +→ effect +→ commit outcome +``` + +Concrete assistant example: + +```text +prepare request context/options and reserve response/usage ids +→ commit assistant.effect_pending +→ invoke and await provider +→ commit response + usage + next durable state +``` + +Tool, hook, timer, and structural procedures follow the same rule where they need durable intent. Recovery reads the last committed state; it does not reconstruct a process-local instruction pointer. + +## Manual-first execution + +Procedure code has one breakpoint call before each exposed transition/effect: + +```ts +await breakpoint({ kind: "assistant.intent", description: "Commit assistant intent" }); +await commitIntent(); + +await breakpoint({ kind: "assistant.request", description: "Request assistant response" }); +const response = await requestAssistant(); + +await breakpoint({ kind: "assistant.outcome", description: "Commit assistant outcome" }); +await commitOutcome(response); +``` + +The same code serves both modes: + +- **automatic:** `breakpoint()` returns immediately; +- **manual:** `breakpoint()` parks the current procedure and exposes `action_required`; +- `executeAction()` releases exactly the current breakpoint and observes the next breakpoint or procedure result; +- `runToCompletion()` repeatedly releases breakpoints; +- `peekAction()` observes without releasing. + +`drive()` only installs/joins one lane-local procedure and observes either its completion or its parked breakpoint. A parked procedure remains the one owner. No completion receipt, activation epoch, retry-lifecycle extension, or durable manual state is needed. + +The minimal process-local shape is: + +```ts +interface ActiveOperation { + operationId: string; + completion: Promise; + barrier: BreakpointBarrier; + effectGate: EffectGate; +} +``` + +Implementation may need a small internal observation promise so `drive()` can return `action_required` while `completion` remains pending. That is breakpoint plumbing, not business state. + +## Recovery + +Recovery is the same procedure selected from durable state when no local owner exists: + +```text +ordinary state +→ continue the next sandwich + +intent/effect_pending with no local owner +→ run that effect type's documented unknown-outcome recovery +→ commit recovery outcome +→ continue +``` + +No caller supplies `recovery:true`. Event metadata is output-only and inferred from durable state plus absent local ownership. + +A process may die after intent but before the effect actually starts. Recovery still treats the effect as uncertain because durable storage cannot distinguish that point from death during the effect. That conservative boundary is the cost of the sandwich. + +## Initial package boundary + +WP05 should implement the smallest end-to-end run path that proves the model: + +1. expected-id `drive` installation/joining and latest-result lookup; +2. shared automatic/manual breakpoint behavior; +3. `before_drive` and `before_run`; +4. `starting → checkpoint`; +5. one no-tool assistant request using intent/effect/outcome; +6. frame persistence and unknown-outcome assistant recovery; +7. no-tool terminal result and cleanup; +8. prompt/skill/template convenience as acceptance followed by drive; +9. exact events, Context propagation, and focused deterministic tests. + +Retry, durable abort, real tools, deferred polling, queues, compaction, and navigation stay out unless implementation proves one is strictly required for the first sandwich to be correct. Do not pull later-package state into WP05 merely to make every future branch executable now. For an unsupported durable branch, stop honestly without fabricating an outcome or effect. + +Accepted pending writes and provider tool/deferred responses need an explicit package-boundary decision before implementation. Prefer a small honest stop over speculative cross-package machinery. + +## Breakpoint rules + +- A breakpoint performs no business work. +- The ordinary statement immediately after it performs the named transition/effect. +- Manual and automatic modes execute byte-identical business code. +- A parked procedure starts no following hook/provider/tool/timer/commit. +- Passive event delivery is never a breakpoint. +- Breakpoint details contain ids/counts only, never prompts, responses, headers, handles, credentials, or tool content. +- Tests release one breakpoint at a time and inspect durable writes/events/state. + +The first catalog should stay small: + +```text +hook.before_drive +hook.before_run +run.start +assistant.intent +assistant.request +assistant.outcome +run.finish +assistant.recover +``` + +Add another breakpoint only when a concrete transition/effect in this package needs independent inspection. + +## Event publication prerequisite + +WP04 owns mutation publication and event-delivery simplification. WP05 uses its `emitBatch()` boundary and does not reopen recipient binding, watcher coherence, direct-listener awaiting, lane creation, or event/hook ordering. + +## Required design checks + +Before source work: + +- prove the process-local breakpoint observer can return an action without resolving/removing the underlying procedure; +- prove same-id drives join and stale ids start nothing; +- prove manual release advances exactly one breakpoint; +- enumerate the first no-tool request's crash positions around intent/effect/outcome; +- define the honest result for tool-call and deferred responses not implemented here; +- define whether accepted pending writes force a small checkpoint drain into this package; +- define close versus a parked procedure and admitted commit without redesigning unrelated lifecycle APIs; +- update only the normative sections needed by those decisions; +- review the final small handoff with Fable. + +## Non-goals + +- wall-clock drive deadlines or yielded outcomes; +- completion interests or durable scheduler receipts; +- activation epochs/tokens unless a concrete tested race proves the minimal owner cannot be fenced by existing lane state; +- retry metadata added to tool state; +- generic shutdown coordination redesign; +- real tool execution; +- deferred polling; +- durable abort/cancellation reconciliation; +- structural operations; +- coding-agent experimental workers or remote-runtime behavior. + +## Stop condition for design + +The handoff is ready only when it can be explained as: + +```text +accept creates durable work +→ drive owns one direct procedure +→ breakpoint optionally parks +→ prepare intent +→ commit intent +→ effect +→ commit outcome +→ repeat or return +``` + +If the design needs another process-local or durable state machine to explain that trace, simplify it before implementation. diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts index f95af5331..0e975fcc6 100644 --- a/packages/agent/src/harness/events.ts +++ b/packages/agent/src/harness/events.ts @@ -3,15 +3,10 @@ import type { Context } from "./context.ts"; type UntypedEventListener = (event: HarnessEvent, context: Context) => void | Promise; -export interface HarnessEventDelivery { - start(): Promise; -} - /** Passive harness event bus with isolated handler failures. */ export class HarnessEventBus implements Events { private readonly listeners = new Map>(); private readonly watchListeners = new Set(); - private readonly pendingStarts = new Set<() => void>(); private deliveryTail: Promise = Promise.resolve(); private closedError: Error | undefined; @@ -32,43 +27,21 @@ export class HarnessEventBus implements Events { } emit(event: HarnessEvent, context: Context): Promise { - return this.enqueue([event], context).start(); + return this.emitBatch([event], context); } - /** - * Bind and reserve one contiguous event batch without permitting listener execution until `start()`. - * Commit owners call this before releasing their serialization line, then call `start()` only after release. - * Prefer `Lane.command`; a dropped or early-started delivery breaks snapshot/event boundary semantics. - */ - enqueue(events: readonly HarnessEvent[], context: Context): HarnessEventDelivery { - if (this.closedError !== undefined || events.length === 0) return { start: () => Promise.resolve() }; + /** Bind current recipients and append one contiguous batch to the global delivery tail. */ + emitBatch(events: readonly HarnessEvent[], context: Context): Promise { + if (this.closedError !== undefined || events.length === 0) return Promise.resolve(); const bound = events.map((event) => { const payload = structuredClone(event); return { payload, recipients: this.snapshotRecipients(payload) }; }); - let release: (() => void) | undefined; - const gate = new Promise((resolve) => { - release = resolve; - }); const delivery = this.deliveryTail.then(async () => { - await gate; for (const { payload, recipients } of bound) await this.deliver(payload, recipients, true, context); }); this.deliveryTail = delivery.catch(() => {}); - let started = false; - const start = (): void => { - if (started) return; - started = true; - this.pendingStarts.delete(start); - release?.(); - }; - this.pendingStarts.add(start); - return { - start: () => { - start(); - return delivery; - }, - }; + return delivery; } watch(snapshot: T, filter: (event: HarnessEvent) => boolean, _context: Context): WatchHandle { @@ -94,7 +67,6 @@ export class HarnessEventBus implements Events { close(error: Error): void { this.closedError ??= error; - for (const start of [...this.pendingStarts]) start(); void this.deliveryTail.finally(() => { this.listeners.clear(); this.watchListeners.clear(); diff --git a/packages/agent/src/harness/runtime2/harness.ts b/packages/agent/src/harness/runtime2/harness.ts index c95b0bc0d..fc8047e6f 100644 --- a/packages/agent/src/harness/runtime2/harness.ts +++ b/packages/agent/src/harness/runtime2/harness.ts @@ -14,12 +14,11 @@ import { Closed, HarnessClosed, HarnessFault, InvalidLane, LaneExists, UnknownTa 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, type HarnessEventDelivery } from "../events.ts"; +import { HarnessEventBus } from "../events.ts"; import { HookRegistry } from "../hooks.ts"; import { convertToLlm } from "../messages.ts"; import { Result } from "../result.ts"; import { - createLaneWithMutator, SessionInvalidLaneError, SessionInvariantError, SessionLaneExistsError, @@ -70,7 +69,7 @@ export class Harness extends Lane implement options.models, main, (cause, context) => this.fault(cause, context), - (events, context) => this.events.enqueue(events, context), + (events, context) => this.events.emitBatch(events, context), (snapshot, filter, context) => this.events.watch(snapshot, filter, context), () => configStore.value, ); @@ -125,21 +124,18 @@ export class Harness extends Lane implement operation: null, }; try { - let lane: Lane | undefined; - let delivery: HarnessEventDelivery | undefined; - await this.session.mutate( + const lane = this.buildLane(name, state); + await this.session.createLane( name, - async (mutator) => { - await createLaneWithMutator(mutator, name, at, this.seed, context); - lane = this.buildLane(name, state); + at, + this.seed, + (context) => { if (this.closedError !== undefined) lane.seal(this.closedError); this.lanesByName.set(name, lane); - delivery = this.events.enqueue([{ type: "lane_created", lane: name, at }], context); + return this.events.emitBatch([{ type: "lane_created", lane: name, at }], context); }, context, ); - await delivery?.start(); - if (lane === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(name)} was not published`); return Result.ok(lane); } catch (error) { // Session has no Result layer, so its expected validation failures are thrown. Translate only those failures @@ -227,7 +223,7 @@ export class Harness extends Lane implement this.models, state, (cause, context) => this.fault(cause, context), - (events, context) => this.events.enqueue(events, context), + (events, context) => this.events.emitBatch(events, context), (snapshot, filter, context) => this.events.watch(snapshot, filter, context), () => this.configStore.value, ); diff --git a/packages/agent/src/harness/runtime2/lane.ts b/packages/agent/src/harness/runtime2/lane.ts index b0011d23d..b68a56d4f 100644 --- a/packages/agent/src/harness/runtime2/lane.ts +++ b/packages/agent/src/harness/runtime2/lane.ts @@ -25,7 +25,6 @@ import { type WatchHandle, } from "../agent-harness.ts"; import type { Context } from "../context.ts"; -import type { HarnessEventDelivery } from "../events.ts"; import { formatPromptTemplateInvocation } from "../prompt-templates.ts"; import { Result } from "../result.ts"; import { insertEntry } from "../session/commit.ts"; @@ -61,7 +60,7 @@ import { import { formatSkillInvocation } from "../skills.ts"; import { type AcceptanceConfig, type LaneState, SliceNotImplemented } from "./types.ts"; -type EventHandler = (events: readonly HarnessEvent[], context: Context) => HarnessEventDelivery; +type EmitBatch = (events: readonly HarnessEvent[], context: Context) => Promise; type WatchHandler = (snapshot: T, filter: (event: HarnessEvent) => boolean, context: Context) => WatchHandle; type FaultHandler = (cause: unknown, context: Context) => Error; type Synchronous = TResult extends PromiseLike ? never : TResult; @@ -78,7 +77,7 @@ type LaneCommand = | { kind: "reject"; error: Error }; type LaneCommandOutcome = - | { kind: "return"; result: TResult; delivery?: HarnessEventDelivery } + | { kind: "return"; result: TResult; delivery?: Promise } | { kind: "reject"; error: Error }; function isPromiseLike(value: unknown): value is PromiseLike { @@ -120,7 +119,7 @@ export class Lane implements AgentLane { protected readonly session: Session; protected readonly models: Models; private readonly onFault: FaultHandler; - private readonly onEvent: EventHandler; + private readonly emitBatch: EmitBatch; private readonly installWatch: WatchHandler; private readonly getAcceptanceConfig: () => AcceptanceConfig; state: LaneState; @@ -132,7 +131,7 @@ export class Lane implements AgentLane { models: Models, state: LaneState, onFault: FaultHandler, - onEvent: EventHandler, + emitBatch: EmitBatch, installWatch: WatchHandler, getAcceptanceConfig: () => AcceptanceConfig, ) { @@ -153,7 +152,7 @@ export class Lane implements AgentLane { }; this.state = state; this.onFault = onFault; - this.onEvent = onEvent; + this.emitBatch = emitBatch; this.installWatch = installWatch; this.getAcceptanceConfig = getAcceptanceConfig; } @@ -215,7 +214,7 @@ export class Lane implements AgentLane { throw new TypeError("Lane command materialize() must be synchronous"); } const events = decision.events?.(commit) ?? []; - const delivery = events.length === 0 ? undefined : this.onEvent(events, context); + const delivery = events.length === 0 ? undefined : this.emitBatch(events, context); return { kind: "return", result, ...(delivery === undefined ? {} : { delivery }) }; } } @@ -231,7 +230,7 @@ export class Lane implements AgentLane { throw error; } if (outcome.kind === "reject") throw outcome.error; - await outcome.delivery?.start(); + await outcome.delivery; return outcome.result; } diff --git a/packages/agent/src/harness/session/memory.ts b/packages/agent/src/harness/session/memory.ts index 2076df8df..162698dfe 100644 --- a/packages/agent/src/harness/session/memory.ts +++ b/packages/agent/src/harness/session/memory.ts @@ -244,10 +244,11 @@ class MemorySessionFacade implements Session { name: string, at: string | null, configuration: LaneConfiguration, + onCommitted: ((context: Context) => void | Promise) | undefined, context: Context, ): Promise { return this.admit(async () => { - await this.session.createLane(name, at, configuration, context); + await this.session.createLane(name, at, configuration, onCommitted, context); return this.view(name); }); } diff --git a/packages/agent/src/harness/session/remote.ts b/packages/agent/src/harness/session/remote.ts index 24499dfc6..910c5dbc0 100644 --- a/packages/agent/src/harness/session/remote.ts +++ b/packages/agent/src/harness/session/remote.ts @@ -294,6 +294,7 @@ export class RemoteSession name: string, at: string | null, configuration: LaneConfiguration, + onCommitted: ((context: Context) => void | Promise) | undefined, context: Context, ): Promise { this.assertOpen(); @@ -306,6 +307,7 @@ export class RemoteSession if (code === "session_unknown_target" && at !== null) throw new SessionUnknownTargetError(at); throw error; } + await onCommitted?.(context); return this.view(name); } diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 12cb131ce..53c14de9b 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -100,65 +100,6 @@ export class SessionUnknownTargetError extends Error { } } -/** - * Package-internal lane creation procedure for callers that own the lane mutation callback. - * - * TODO: Replace this one-off procedure if lane creation gains a shared command abstraction. The Harness must own the - * new lane's mutation so the durable commit, in-memory lane publication, and recipient-bound `lane_created` enqueue - * all happen before line release. Calling event-free `Session.createLane()` and emitting afterward would permit both - * snapshot-after plus a stale event and delivery to listeners registered after the commit. - */ -export async function createLaneWithMutator( - mutator: SessionMutator, - name: string, - at: string | null, - configuration: LaneConfiguration, - context: Context, -): Promise { - if (mutator.lane !== name) { - throw new SessionInvariantError( - `Lane mutation for ${JSON.stringify(mutator.lane)} cannot create ${JSON.stringify(name)}`, - ); - } - if (name.length === 0) throw new SessionInvalidLaneError(name, "lane name must not be empty"); - - // R1 owns complete idle-lane and current-state validation. Slice 2 only - // distinguishes valid existing lane shapes from partial durable lane state. - const [leaf, storedConfiguration, storedLaneState, lastResult] = await Promise.all([ - mutator.getValue(laneLeaf(name), context), - mutator.getValue(laneConfig(name), context), - mutator.getValue(laneState(name), context), - mutator.getValue(laneLastResult(name), context), - ]); - const presentCount = [leaf, storedConfiguration, storedLaneState, lastResult].filter( - (stored) => stored !== undefined, - ).length; - if ( - leaf !== undefined && - storedLaneState !== undefined && - (storedConfiguration !== undefined || (name === "main" && lastResult === undefined)) - ) { - throw new SessionLaneExistsError(name); - } - if (presentCount !== 0) { - throw new SessionInvariantError(`Lane ${JSON.stringify(name)} has incomplete durable state`); - } - if (at !== null && !(await mutator.getEntries([at], context)).has(at)) { - throw new SessionUnknownTargetError(at); - } - - // R6 adds the harness-wide admission barrier. Until then, close may reject - // this lane job before Storage.commit admits it; admitted commits still drain. - await mutator.commit( - [ - setValueWrite(laneConfig(name), configuration), - setValueWrite(laneLeaf(name), at), - setValueWrite(laneState(name), { currentOperationId: null, pendingNextRun: [] }), - ], - context, - ); -} - class StorageBackedSessionMutator implements SessionMutator { readonly lane: string; private readonly storage: Storage; @@ -335,10 +276,58 @@ export class StorageBackedSession void | Promise) | undefined, context: Context, ): Promise { this.assertOpen(); - await this.mutate(name, (mutator) => createLaneWithMutator(mutator, name, at, configuration, context), context); + let completion: Promise | undefined; + await this.mutate( + name, + async (mutator) => { + if (mutator.lane !== name) { + throw new SessionInvariantError( + `Lane mutation for ${JSON.stringify(mutator.lane)} cannot create ${JSON.stringify(name)}`, + ); + } + if (name.length === 0) throw new SessionInvalidLaneError(name, "lane name must not be empty"); + + const [leaf, storedConfiguration, storedLaneState, lastResult] = await Promise.all([ + mutator.getValue(laneLeaf(name), context), + mutator.getValue(laneConfig(name), context), + mutator.getValue(laneState(name), context), + mutator.getValue(laneLastResult(name), context), + ]); + const presentCount = [leaf, storedConfiguration, storedLaneState, lastResult].filter( + (stored) => stored !== undefined, + ).length; + if ( + leaf !== undefined && + storedLaneState !== undefined && + (storedConfiguration !== undefined || (name === "main" && lastResult === undefined)) + ) { + throw new SessionLaneExistsError(name); + } + if (presentCount !== 0) { + throw new SessionInvariantError(`Lane ${JSON.stringify(name)} has incomplete durable state`); + } + if (at !== null && !(await mutator.getEntries([at], context)).has(at)) { + throw new SessionUnknownTargetError(at); + } + + await mutator.commit( + [ + setValueWrite(laneConfig(name), configuration), + setValueWrite(laneLeaf(name), at), + setValueWrite(laneState(name), { currentOperationId: null, pendingNextRun: [] }), + ], + context, + ); + const result = onCommitted?.(context); + completion = result === undefined ? undefined : Promise.resolve(result); + }, + context, + ); + await completion; return this.view(name); } diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 4ffbed3f4..a2792cba8 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -540,6 +540,7 @@ export interface Session ex name: string, at: string | null, configuration: LaneConfiguration, + onCommitted: ((context: Context) => void | Promise) | undefined, context: Context, ): Promise; close(context: Context): Promise; diff --git a/packages/agent/test/harness/execution-primitives.test.ts b/packages/agent/test/harness/execution-primitives.test.ts index f1fb469ca..c5839d936 100644 --- a/packages/agent/test/harness/execution-primitives.test.ts +++ b/packages/agent/test/harness/execution-primitives.test.ts @@ -491,7 +491,39 @@ describe("HarnessEventBus", () => { expect(seen).toEqual(["one:start", "one:end", "two:start", "two:end"]); }); - it("binds listeners and watchers when an event is enqueued", async () => { + it("keeps concurrent batches contiguous with their emitting contexts", async () => { + const bus = new HarnessEventBus(); + const sourceKey = createContextKey("event.batch.source"); + const firstContext = withContextValue(sourceKey, "first", BACKGROUND_CONTEXT); + const secondContext = withContextValue(sourceKey, "second", BACKGROUND_CONTEXT); + const seen: Array<{ runId: string; context: typeof firstContext }> = []; + bus.on("run_start", async (event, context) => { + seen.push({ runId: event.runId, context }); + await Promise.resolve(); + }); + + const first = bus.emitBatch( + [ + { type: "run_start", runId: "a1", lane: "main" }, + { type: "run_start", runId: "a2", lane: "main" }, + ], + firstContext, + ); + const second = bus.emitBatch( + [ + { type: "run_start", runId: "b1", lane: "worker" }, + { type: "run_start", runId: "b2", lane: "worker" }, + ], + secondContext, + ); + + await Promise.all([first, second]); + expect(seen.map(({ runId }) => runId)).toEqual(["a1", "a2", "b1", "b2"]); + expect(seen.slice(0, 2).every(({ context }) => context === firstContext)).toBe(true); + expect(seen.slice(2).every(({ context }) => context === secondContext)).toBe(true); + }); + + it("binds listeners and watchers when a batch is emitted", async () => { const bus = new HarnessEventBus(); const started = deferred(); const release = deferred(); @@ -524,19 +556,44 @@ describe("HarnessEventBus", () => { expect(lateWatcherEvents).toEqual(["later"]); }); - it("releases reserved delivery gates during close", async () => { + it("resolves an empty batch without delivery", async () => { const bus = new HarnessEventBus(); + const listener = vi.fn(); + bus.on("run_start", listener); + + await bus.emitBatch([], BACKGROUND_CONTEXT); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("drains already emitted contiguous batches during close and ignores later publication", async () => { + const bus = new HarnessEventBus(); + const started = deferred(); + const release = deferred(); const seen: string[] = []; - bus.on("run_start", (event) => { + bus.on("run_start", async (event) => { seen.push(event.runId); + if (event.runId === "blocking") { + started.resolve(); + await release.promise; + } }); - const delivery = bus.enqueue([{ type: "run_start", runId: "reserved", lane: "main" }], BACKGROUND_CONTEXT); + const blocking = bus.emit({ type: "run_start", runId: "blocking", lane: "main" }, BACKGROUND_CONTEXT); + await started.promise; + const batch = bus.emitBatch( + [ + { type: "run_start", runId: "one", lane: "main" }, + { type: "run_start", runId: "two", lane: "main" }, + ], + BACKGROUND_CONTEXT, + ); bus.close(new Error("closed")); - await new Promise((resolve) => setTimeout(resolve, 0)); + await bus.emit({ type: "run_start", runId: "late", lane: "main" }, BACKGROUND_CONTEXT); + release.resolve(); + await Promise.all([blocking, batch]); - expect(seen).toEqual(["reserved"]); - await delivery.start(); + expect(seen).toEqual(["blocking", "one", "two"]); }); it("continues watcher delivery after listener failure and reports it", async () => { @@ -572,4 +629,20 @@ describe("HarnessEventBus", () => { await bus.emit({ type: "run_start", runId: "run", lane: "main" }, BACKGROUND_CONTEXT); expect(failures).toEqual(["listener failed"]); }); + + it("does not recurse when a handler_error listener fails", async () => { + const bus = new HarnessEventBus(); + let handlerErrors = 0; + bus.on("run_start", () => { + throw new Error("listener failed"); + }); + bus.on("handler_error", () => { + handlerErrors += 1; + throw new Error("error listener failed"); + }); + + await bus.emit({ type: "run_start", runId: "run", lane: "main" }, BACKGROUND_CONTEXT); + + expect(handlerErrors).toBe(1); + }); }); diff --git a/packages/agent/test/harness/runtime2/accept.test.ts b/packages/agent/test/harness/runtime2/accept.test.ts index 09d193299..0bde9488a 100644 --- a/packages/agent/test/harness/runtime2/accept.test.ts +++ b/packages/agent/test/harness/runtime2/accept.test.ts @@ -322,7 +322,7 @@ describe("runtime2 atomic run acceptance", () => { expect(harness.state.operation).toBeNull(); }); - it("delivers acceptance listeners outside the lane line after publishing state", async () => { + it("delivers acceptance listeners after publishing state and permits serialized reads", async () => { const { harness } = await createHarness(); let inspected = false; harness.events.on("run_start", async () => { @@ -334,6 +334,28 @@ describe("runtime2 atomic run acceptance", () => { expect(inspected).toBe(true); }); + it("does not resolve acceptance before its direct listeners settle", async () => { + const { harness } = await createHarness(); + const started = deferred(); + const release = deferred(); + harness.events.on("run_start", async () => { + started.resolve(); + await release.promise; + }); + + const acceptance = harness.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT); + await started.promise; + let resolved = false; + void acceptance.then(() => { + resolved = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(resolved).toBe(false); + release.resolve(); + unwrap(await acceptance); + expect(resolved).toBe(true); + }); + it("returns Closed when acceptance starts after close", async () => { const { harness, storage } = await createHarness(); await harness.close(BACKGROUND_CONTEXT); diff --git a/packages/agent/test/harness/runtime2/harness.test.ts b/packages/agent/test/harness/runtime2/harness.test.ts index 8a22897a8..ede2493cb 100644 --- a/packages/agent/test/harness/runtime2/harness.test.ts +++ b/packages/agent/test/harness/runtime2/harness.test.ts @@ -416,7 +416,7 @@ describe("runtime2 AgentHarness", () => { ), BACKGROUND_CONTEXT, ); - await session.createLane("worker", null, workerConfiguration, BACKGROUND_CONTEXT); + await session.createLane("worker", null, workerConfiguration, undefined, BACKGROUND_CONTEXT); const before = await Promise.all([ session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT), session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT), @@ -479,6 +479,67 @@ describe("runtime2 AgentHarness", () => { }); }); + it("publishes a created lane before a duplicate observes it and awaits lane_created listeners", async () => { + const session = await createSession(); + const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); + const listenerStarted = deferred(); + const releaseListener = deferred(); + harness.events.on("lane_created", async (event) => { + if (event.lane !== "worker") return; + listenerStarted.resolve(); + await releaseListener.promise; + }); + + const winner = harness.createLane("worker", null, BACKGROUND_CONTEXT); + await listenerStarted.promise; + let winnerResolved = false; + void winner.then(() => { + winnerResolved = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(winnerResolved).toBe(false); + const visible = await harness.lane("worker", BACKGROUND_CONTEXT); + expect(visible).toBeInstanceOf(Lane); + + const duplicate = await harness.createLane("worker", null, BACKGROUND_CONTEXT); + expect(duplicate).toMatchObject({ ok: false, error: { _tag: "LaneExists", lane: "worker" } }); + expect(await harness.lane("worker", BACKGROUND_CONTEXT)).toBe(visible); + + releaseListener.resolve(); + expect(unwrap(await winner)).toBe(visible); + expect(winnerResolved).toBe(true); + }); + + it("globally orders complete event batches from concurrent lanes", async () => { + const session = await createSession(); + const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); + const worker = unwrap(await harness.createLane("worker", null, BACKGROUND_CONTEXT)); + const seen: Array<{ type: string; lane: string }> = []; + for (const type of ["message_start", "message_end", "entry_added"] as const) { + harness.events.on(type, (event) => { + seen.push({ type: event.type, lane: event.lane }); + }); + } + + await Promise.all([ + harness.sessionTree.appendMessage({ role: "user", content: "main", timestamp: 1 }, BACKGROUND_CONTEXT), + worker.sessionTree.appendMessage({ role: "user", content: "worker", timestamp: 2 }, BACKGROUND_CONTEXT), + ]); + + expect(seen.map(({ type }) => type)).toEqual([ + "message_start", + "message_end", + "entry_added", + "message_start", + "message_end", + "entry_added", + ]); + expect(new Set(seen.map(({ lane }) => lane))).toEqual(new Set(["main", "worker"])); + expect(seen.slice(0, 3).every(({ lane }) => lane === seen[0]?.lane)).toBe(true); + expect(seen.slice(3).every(({ lane }) => lane === seen[3]?.lane)).toBe(true); + expect(seen[0]?.lane).not.toBe(seen[3]?.lane); + }); + it("binds metadata and lane-creation recipients before releasing their mutation lines", async () => { const session = await createSession(); const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); diff --git a/packages/agent/test/harness/runtime2/lane.test.ts b/packages/agent/test/harness/runtime2/lane.test.ts index 4f7c3f4ad..3f472be50 100644 --- a/packages/agent/test/harness/runtime2/lane.test.ts +++ b/packages/agent/test/harness/runtime2/lane.test.ts @@ -1,8 +1,9 @@ import { type Api, createModels, fauxProvider, type Model } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import { HarnessClosed } from "../../../src/harness/agent-harness.ts"; +import { HarnessClosed, type HarnessEvent } from "../../../src/harness/agent-harness.ts"; import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts"; -import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; +import { BACKGROUND_CONTEXT, type Context } from "../../../src/harness/context.ts"; +import { HarnessEventBus } from "../../../src/harness/events.ts"; import { Lane } from "../../../src/harness/runtime2/lane.ts"; import { restoreLane } from "../../../src/harness/runtime2/restore.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; @@ -17,7 +18,9 @@ const configuration: LaneConfiguration = { activeToolNames: [], }; -async function createLane(): Promise<{ +async function createLane( + emitBatch: (events: readonly HarnessEvent[], context: Context) => Promise = () => Promise.resolve(), +): Promise<{ lane: Lane; model: Model; session: Session; @@ -52,7 +55,7 @@ async function createLane(): Promise<{ models, await restoreLane(session, "main", BACKGROUND_CONTEXT), (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - () => ({ start: () => Promise.resolve() }), + emitBatch, (snapshot) => ({ snapshot, start: () => {}, unsubscribe: () => {} }), () => ({ resources: {}, @@ -205,6 +208,35 @@ describe("runtime2 Lane commands", () => { ); }); + it("preserves committed memory when synchronous event publication fails", async () => { + const bus = new HarnessEventBus(); + const { lane, session } = await createLane((events, context) => bus.emitBatch(events, context)); + const uncloneable = { + type: "run_start", + runId: "run", + lane: "main", + invalid: () => undefined, + } as unknown as HarnessEvent; + + await expect( + lane.command((state) => { + const configuration: LaneConfiguration = { ...state.configuration, thinkingLevel: "high" }; + return { + kind: "commit", + writes: [storedValues.setValue(storedValues.laneConfig("main"), configuration)], + next: { ...state, configuration }, + materialize: () => undefined, + events: () => [uncloneable], + }; + }, BACKGROUND_CONTEXT), + ).rejects.toMatchObject({ name: "DataCloneError" }); + + expect(lane.state.configuration.thinkingLevel).toBe("high"); + expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value.thinkingLevel).toBe( + "high", + ); + }); + it("rejects work after sealing while an admitted commit finishes", async () => { const { lane, storage } = await createLane(); const commitStarted = deferred(); diff --git a/packages/agent/test/harness/runtime2/restore.test.ts b/packages/agent/test/harness/runtime2/restore.test.ts index ab3a496c2..0e1b9a658 100644 --- a/packages/agent/test/harness/runtime2/restore.test.ts +++ b/packages/agent/test/harness/runtime2/restore.test.ts @@ -208,7 +208,7 @@ describe("runtime2 lane restore", () => { thinkingLevel: "high", activeToolNames: ["read"], }; - await session.createLane("worker", null, workerConfiguration, BACKGROUND_CONTEXT); + await session.createLane("worker", null, workerConfiguration, undefined, BACKGROUND_CONTEXT); const operationId = session.idGenerator.next(); const meta: OperationMeta = { operationId, diff --git a/packages/agent/test/harness/session-create-lane.test.ts b/packages/agent/test/harness/session-create-lane.test.ts index 3dcc5f3a0..16d8559c7 100644 --- a/packages/agent/test/harness/session-create-lane.test.ts +++ b/packages/agent/test/harness/session-create-lane.test.ts @@ -6,8 +6,8 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import * as sessionWrites from "../../src/harness/session/commit.ts"; import { MemoryStorage } from "../../src/harness/session/memory.ts"; import { - createLaneWithMutator, SessionInvariantError, + SessionLaneExistsError, StorageBackedSession, } from "../../src/harness/session/session.ts"; import { InstrumentedStorage, StorageDecorator } from "../../src/harness/session/testing/index.ts"; @@ -105,31 +105,41 @@ class BlockingCommitStorage extends StorageDecorator { describe("StorageBackedSession.createLane", () => { it("completes the package-internal Session contract", () => { expectTypeOf().toMatchTypeOf(); - expectTypeOf(createLaneWithMutator).returns.toEqualTypeOf>(); }); - it("exposes the same procedure to callers that own the lane mutation", async () => { + it("publishes before a queued duplicate observes the lane and awaits callback completion off the line", async () => { const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); const session = new StorageBackedSession(metadata, storage); await commitSession(session, rootTransaction()); storage.clearCommitAttempts(); + const callbackDone = deferred(); + let publishCount = 0; - await session.mutate( + const creating = session.createLane( "shared", - (mutator) => createLaneWithMutator(mutator, "shared", ROOT_ID, configuration, BACKGROUND_CONTEXT), + ROOT_ID, + configuration, + (context) => { + expect(context).toBe(BACKGROUND_CONTEXT); + publishCount += 1; + return callbackDone.promise; + }, BACKGROUND_CONTEXT, ); + const duplicate = session.createLane("shared", null, configuration, undefined, BACKGROUND_CONTEXT); + await expect(duplicate).rejects.toBeInstanceOf(SessionLaneExistsError); + expect(publishCount).toBe(1); expect(storage.getCommitAttempts()).toEqual([expectedLaneWrites("shared", ROOT_ID, configuration)]); - await expect(session.view("shared").getLeafId(BACKGROUND_CONTEXT)).resolves.toBe(ROOT_ID); - await expect( - session.mutate( - "different-line", - (mutator) => createLaneWithMutator(mutator, "other", null, configuration, BACKGROUND_CONTEXT), - BACKGROUND_CONTEXT, - ), - ).rejects.toBeInstanceOf(SessionInvariantError); - expect(storage.getCommitAttempts()).toHaveLength(1); + let resolved = false; + void creating.then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); + callbackDone.resolve(); + await creating; + expect(resolved).toBe(true); await session.close(BACKGROUND_CONTEXT); }); @@ -139,8 +149,8 @@ describe("StorageBackedSession.createLane", () => { await commitSession(session, rootTransaction()); storage.clearCommitAttempts(); - const rooted = await session.createLane("rooted", ROOT_ID, configuration, BACKGROUND_CONTEXT); - const empty = await session.createLane("empty", null, configuration, BACKGROUND_CONTEXT); + const rooted = await session.createLane("rooted", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); + const empty = await session.createLane("empty", null, configuration, undefined, BACKGROUND_CONTEXT); expect(storage.getCommitAttempts()).toEqual([ expectedLaneWrites("rooted", ROOT_ID, configuration), @@ -172,7 +182,7 @@ describe("StorageBackedSession.createLane", () => { activeToolNames: [...configuration.activeToolNames], }; - await session.createLane("captured", ROOT_ID, supplied, BACKGROUND_CONTEXT); + await session.createLane("captured", ROOT_ID, supplied, undefined, BACKGROUND_CONTEXT); expect((await session.getValue(storedValues.laneConfig("captured"), BACKGROUND_CONTEXT))?.value).toBe(supplied); expect(storage.getCommitAttempts()).toHaveLength(1); @@ -186,11 +196,20 @@ describe("StorageBackedSession.createLane", () => { const session = new StorageBackedSession(metadata, storage); await commitSession(session, rootTransaction()); storage.clearCommitAttempts(); + let callbackCalled = false; - await expect(session.createLane("", ROOT_ID, configuration, BACKGROUND_CONTEXT)).rejects.toMatchObject({ - name: "SessionInvalidLaneError", - lane: "", - }); + await expect( + session.createLane( + "", + ROOT_ID, + configuration, + () => { + callbackCalled = true; + }, + BACKGROUND_CONTEXT, + ), + ).rejects.toMatchObject({ name: "SessionInvalidLaneError", lane: "" }); + expect(callbackCalled).toBe(false); expect(storage.getCommitAttempts()).toEqual([]); await session.close(BACKGROUND_CONTEXT); }); @@ -199,11 +218,13 @@ describe("StorageBackedSession.createLane", () => { const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); const session = new StorageBackedSession(metadata, storage); await commitSession(session, rootTransaction()); - await session.createLane("existing", ROOT_ID, configuration, BACKGROUND_CONTEXT); + await session.createLane("existing", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); storage.clearCommitAttempts(); for (const name of ["existing", "main"]) { - await expect(session.createLane(name, null, configuration, BACKGROUND_CONTEXT)).rejects.toMatchObject({ + await expect( + session.createLane(name, null, configuration, undefined, BACKGROUND_CONTEXT), + ).rejects.toMatchObject({ name: "SessionLaneExistsError", lane: name, }); @@ -219,7 +240,7 @@ describe("StorageBackedSession.createLane", () => { storage.clearCommitAttempts(); await expect( - session.createLane("missing-target", MISSING_ID, configuration, BACKGROUND_CONTEXT), + session.createLane("missing-target", MISSING_ID, configuration, undefined, BACKGROUND_CONTEXT), ).rejects.toMatchObject({ name: "SessionUnknownTargetError", targetId: MISSING_ID, @@ -253,9 +274,9 @@ describe("StorageBackedSession.createLane", () => { ); storage.clearCommitAttempts(); - await expect(session.createLane("broken", null, configuration, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( - SessionInvariantError, - ); + await expect( + session.createLane("broken", null, configuration, undefined, BACKGROUND_CONTEXT), + ).rejects.toBeInstanceOf(SessionInvariantError); expect(storage.getCommitAttempts()).toEqual([]); await session.close(BACKGROUND_CONTEXT); } @@ -268,8 +289,8 @@ describe("StorageBackedSession.createLane", () => { storage.clearCommitAttempts(); const results = await Promise.allSettled([ - session.createLane("race", ROOT_ID, configuration, BACKGROUND_CONTEXT), - session.createLane("race", null, configuration, BACKGROUND_CONTEXT), + session.createLane("race", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT), + session.createLane("race", null, configuration, undefined, BACKGROUND_CONTEXT), ]); const fulfilled = results.filter((result) => result.status === "fulfilled"); @@ -287,7 +308,7 @@ describe("StorageBackedSession.createLane", () => { const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); await commitSession(session, rootTransaction()); - const creating = session.createLane("created-first", ROOT_ID, configuration, BACKGROUND_CONTEXT); + const creating = session.createLane("created-first", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); const appended = session.view("created-first").appendCustomEntry("note", undefined, BACKGROUND_CONTEXT); const [, entryId] = await Promise.all([creating, appended]); expect(await session.view("created-first").getLeafId(BACKGROUND_CONTEXT)).toBe(entryId); @@ -297,7 +318,7 @@ describe("StorageBackedSession.createLane", () => { }); const rejectedAppend = session.view("append-first").appendCustomEntry("note", undefined, BACKGROUND_CONTEXT); - const laterCreation = session.createLane("append-first", ROOT_ID, configuration, BACKGROUND_CONTEXT); + const laterCreation = session.createLane("append-first", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); await expect(rejectedAppend).rejects.toBeInstanceOf(SessionInvariantError); await laterCreation; expect(await session.view("append-first").getLeafId(BACKGROUND_CONTEXT)).toBe(ROOT_ID); @@ -311,8 +332,20 @@ describe("StorageBackedSession.createLane", () => { await commitSession(session, rootTransaction()); const rejection = new Error("commit failed"); storage.rejection = rejection; + let callbackCalled = false; - await expect(session.createLane("failed", ROOT_ID, configuration, BACKGROUND_CONTEXT)).rejects.toBe(rejection); + await expect( + session.createLane( + "failed", + ROOT_ID, + configuration, + () => { + callbackCalled = true; + }, + BACKGROUND_CONTEXT, + ), + ).rejects.toBe(rejection); + expect(callbackCalled).toBe(false); storage.rejection = undefined; expect(await session.getValue(storedValues.laneConfig("failed"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await session.getValue(storedValues.laneLeaf("failed"), BACKGROUND_CONTEXT)).toBeUndefined(); @@ -320,20 +353,46 @@ describe("StorageBackedSession.createLane", () => { await session.close(BACKGROUND_CONTEXT); }); + it("retains committed lanes when publication throws or rejects", async () => { + const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); + await commitSession(session, rootTransaction()); + const thrown = new Error("publication threw"); + const rejected = new Error("publication rejected"); + + await expect( + session.createLane( + "thrown", + ROOT_ID, + configuration, + () => { + throw thrown; + }, + BACKGROUND_CONTEXT, + ), + ).rejects.toBe(thrown); + await expect( + session.createLane("rejected", ROOT_ID, configuration, () => Promise.reject(rejected), BACKGROUND_CONTEXT), + ).rejects.toBe(rejected); + + await expect(session.view("thrown").getLeafId(BACKGROUND_CONTEXT)).resolves.toBe(ROOT_ID); + await expect(session.view("rejected").getLeafId(BACKGROUND_CONTEXT)).resolves.toBe(ROOT_ID); + await session.close(BACKGROUND_CONTEXT); + }); + it("drains creation after its commit is admitted to storage and rejects creation after close", async () => { const storage = new BlockingCommitStorage(new MemoryStorage({ now: () => NOW })); const session = new StorageBackedSession(metadata, storage); await commitSession(session, rootTransaction()); storage.block = true; - const creation = session.createLane("admitted", ROOT_ID, configuration, BACKGROUND_CONTEXT); + const creation = session.createLane("admitted", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); await storage.admitted; const close = session.close(BACKGROUND_CONTEXT); storage.release(); await creation; await close; - await expect(session.createLane("late", null, configuration, BACKGROUND_CONTEXT)).rejects.toThrow( + await expect(session.createLane("late", null, configuration, undefined, BACKGROUND_CONTEXT)).rejects.toThrow( "Session is closed", ); }); @@ -344,9 +403,9 @@ describe("StorageBackedSession.createLane", () => { await commitSession(session, rootTransaction()); storage.block = true; - const admitted = session.createLane("queued", ROOT_ID, configuration, BACKGROUND_CONTEXT); + const admitted = session.createLane("queued", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); await storage.admitted; - const queued = session.createLane("queued", null, configuration, BACKGROUND_CONTEXT); + const queued = session.createLane("queued", null, configuration, undefined, BACKGROUND_CONTEXT); const close = session.close(BACKGROUND_CONTEXT); storage.release(); diff --git a/packages/agent/test/harness/session-tree.test.ts b/packages/agent/test/harness/session-tree.test.ts index b87a45813..88a5c41e4 100644 --- a/packages/agent/test/harness/session-tree.test.ts +++ b/packages/agent/test/harness/session-tree.test.ts @@ -411,7 +411,7 @@ describe("StorageBackedSession SessionTree", () => { thinkingLevel: "off" as const, activeToolNames: [], }; - const review = await session.createLane("review", ROOT_ID, configuration, BACKGROUND_CONTEXT); + const review = await session.createLane("review", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); const [mainId, reviewId] = await Promise.all([ session.appendCustomEntry("main-child", undefined, BACKGROUND_CONTEXT), diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index f4c06938a..82c77c65d 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -391,7 +391,13 @@ it("covers storage, session, repository, search, and identity signatures", () => (query: StorageBranchScan, context: Context) => Promise >(); expectTypeOf().toEqualTypeOf< - (name: string, at: string | null, laneConfiguration: LaneConfiguration, context: Context) => Promise + ( + name: string, + at: string | null, + laneConfiguration: LaneConfiguration, + onCommitted: ((context: Context) => void | Promise) | undefined, + context: Context, + ) => Promise >(); expectTypeOf().toEqualTypeOf< (options: SessionCreateOptions, context: Context) => Promise diff --git a/packages/server/src/remote-session-manager.ts b/packages/server/src/remote-session-manager.ts index 8fc07cee7..e7ffcf4ff 100644 --- a/packages/server/src/remote-session-manager.ts +++ b/packages/server/src/remote-session-manager.ts @@ -111,6 +111,7 @@ export class RemoteSessionManager null); diff --git a/packages/server/test/remote-session.test.ts b/packages/server/test/remote-session.test.ts index d9a004843..1bf7fc370 100644 --- a/packages/server/test/remote-session.test.ts +++ b/packages/server/test/remote-session.test.ts @@ -150,6 +150,7 @@ describe("RemoteSession", () => { thinkingLevel: "off", activeToolNames: [], }, + undefined, BACKGROUND_CONTEXT, ); await expect(review.getLeafId(BACKGROUND_CONTEXT)).resolves.toBeNull(); diff --git a/packages/session-backends/sqlite-node/src/sqlite/session.ts b/packages/session-backends/sqlite-node/src/sqlite/session.ts index 28b57f699..b7f8bc317 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/session.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/session.ts @@ -122,10 +122,11 @@ export class SqliteOpenSession implements Session { name: string, at: string | null, configuration: LaneConfiguration, + onCommitted: ((context: Context) => void | Promise) | undefined, context: Context, ): Promise { return this.admit(async () => { - await this.session.createLane(name, at, configuration, context); + await this.session.createLane(name, at, configuration, onCommitted, context); return this.view(name); }); }